chore!: minor fixes here and there & move CRDs into submods & introduce serializer helpers

This commit is contained in:
spushk 2026-06-02 06:10:46 +05:00 • committed by spushk
commit 3ac37809b7
Signed by: spushk
GPG key ID: 7797B761E105E8AA
13 changed files with 811 additions and 1543 deletions

View file

@ -11,8 +11,8 @@ publish.workspace = true
[[bin]]
doc = false
name = "generate_crds"
path = "src/bin/generate_crds.rs"
name = "crdgen"
path = "src/bin/crdgen.rs"
[dependencies]
## Rt
@ -26,7 +26,6 @@ tracing-subscriber.workspace = true
tracing-subscriber.features = ["env-filter"]
tracing-opentelemetry.workspace = true
opentelemetry.workspace = true
opentelemetry-otlp.workspace = true
opentelemetry_sdk.workspace = true
prometheus-client.workspace = true
## Utils
@ -39,6 +38,7 @@ itertools.workspace = true
schemars.workspace = true
base64.workspace = true
serde.workspace = true
serde_with.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
## k8s
@ -55,4 +55,5 @@ ssh-key.workspace = true
ssh-key.features = ["ed25519"]
rand_hc.workspace = true
blake3.workspace = true
blake3.features = ["serde"]
zeroize.workspace = true

View file

@ -0,0 +1,3 @@
use kube::CustomResourceExt;
fn main() {}

View file

@ -1,14 +0,0 @@
use kube::CustomResourceExt;
fn main() {
print!(
"{}",
serde_yaml::to_string(&kuyaseg_controller::resource::generated::secret::v1alpha1::RandomSecret::crd())
.expect("RandomSecret v1alpha1 CRD")
);
print!(
"{}",
serde_yaml::to_string(&kuyaseg_controller::resource::generated::secret::v1alpha1::DeterministicSecret::crd())
.expect("DeterministicSecret v1alpha1 CRD")
)
}

View file

@ -240,8 +240,6 @@ mod test {
verify_ssh_pair(&pair);
}
// ====== Deterministic (Hc128Rng) ======
#[test]
fn deterministic_bytes() {
// we believe that generation is the same as for random, so our concern here is only value stability

View file

@ -50,6 +50,7 @@ pub struct ReportingInit {
pub pro_registry: Option<prometheus_client::registry::Registry>,
}
/// For opentelemetry TraceId to be present, tracing_opentelemetry::layer must also be set
pub fn init(k8s_client: kube::Client, ct: CancellationToken) -> anyhow::Result<ReportingInit> {
let config = ReportingConfig::builder()
.env()

View file

@ -1,609 +1 @@
use std::collections::{
BTreeMap,
HashMap,
};
use anyhow::Context;
use kube::{
Api,
Resource,
};
use schemars::JsonSchema;
use serde::de::DeserializeOwned;
use serde::{
Deserialize,
Serialize,
};
use serde_json::json;
use zeroize::Zeroize;
pub mod v1alpha1;
use crate::utils::zeroizers::{
self,
zeroize_btree,
zeroize_hashmap,
};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GeneratedSecretSpec {
#[serde(default)]
pub keys: Vec<GeneratedSecretKey>,
#[serde(default)]
pub template: GeneratedSecretTemplate,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GeneratedSecretStatus {
/// Stored all generated keys for matching v1/Secret resource, hashed by key name.
/// The matcher value is (version, shape hash)
///
/// Shape hash is obtained with Blake3 hash.
pub generated: BTreeMap<String, (usize, String)>,
}
impl GeneratedSecretStatus {
/// Determine whether the key from spec was already generated, based on:
/// - Key name
/// - Key version
/// - Key shape hash
fn was_generated(&self, spec: &GeneratedSecretKey) -> bool {
let Some(stored) = self.generated.get(&spec.key) else {
return false;
};
(stored.0 == spec.version) && (stored.1 == spec.shape.stable_hash())
}
}
impl GeneratedSecretSpec {
/// Reconcile generated secret from specification, following the specific type `T` generation strategy.
/// This method propagates upstream.
///
/// This method family assumes that upstream resource has `GeneratedSecretStatus` status.
///
/// I wrote this because - hell no - I won't write this code twice for both Random and Deterministic secrets!
async fn reconcile_spec<T>(
&self,
name: &str,
namespace: &str,
client: kube::Client,
uid: Option<&str>,
) -> anyhow::Result<()>
where
T: HasSecretGenerator
+ Resource<DynamicType = (), Scope = k8s_openapi::NamespaceResourceScope>
+ DeserializeOwned,
{
// discover target secret & branch for update/create
let target_api = Api::<k8s_openapi::api::core::v1::Secret>::namespaced(client.clone(), namespace);
let maybe_secret = scopeguard::guard(
{
target_api
.get_opt(name)
.await
.inspect_err(|e| {
tracing::error!(
debug=?e,
critical = true,
reason = "SecretDiscoveryFailed",
action = "Reconcile",
"Failed to discover target Secret"
)
})
.context("Discover target secret")?
},
|mut v| {
v.as_mut().map(zeroizers::zeroize_k8s_secret);
},
);
match maybe_secret.as_ref() {
Some(yep) => self
.update_from_spec::<T>(name, namespace, client, yep)
.await
.context("Update existing Secret")?,
None => self
.create_new_from_spec::<T>(name, namespace, client, uid)
.await
.context("Create new Secret")?,
}
Ok(())
}
async fn create_new_from_spec<T>(
&self,
name: &str,
namespace: &str,
client: kube::Client,
uid: Option<&str>,
) -> anyhow::Result<()>
where
T: HasSecretGenerator
+ Resource<DynamicType = (), Scope = k8s_openapi::NamespaceResourceScope>
+ DeserializeOwned,
{
use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference;
let self_api = Api::<T>::namespaced(client.clone(), namespace);
let target_api = Api::<k8s_openapi::api::core::v1::Secret>::namespaced(client.clone(), namespace);
let generated = self
.keys
.iter()
.map(|key| (key.key.clone(), (key.version, key.shape.stable_hash())))
.collect::<BTreeMap<_, _>>();
// first we try with dry-run, no need to guard as no data here
let owner_ref = uid.map(|uid| OwnerReference {
api_version: T::api_version(&()).to_string(),
kind: T::kind(&()).to_string(),
name: name.to_string(),
uid: uid.to_string(),
controller: Some(true),
..Default::default()
});
let partial_target = {
let mut secret = self.template.to_safe_partial_secret(Some(false), Some("Opaque".into()));
secret.metadata.owner_references = owner_ref.as_ref().map(|oref| vec![oref.clone()]);
secret
};
let patch = kube::api::Patch::Apply(json!({
"apiVersion": T::api_version(&()),
"kind": T::kind(&()),
"status": GeneratedSecretStatus {
generated
}
}));
// dry-run
{
target_api
.create(
&kube::api::PostParams {
dry_run: true,
field_manager: Some(env!("CARGO_PKG_NAME").into()),
},
&partial_target,
)
.await
.inspect_err(|e| {
tracing::error!(
debug=?e,
reason = "SecretCreationFailed",
action = "CreateNew",
"Failed to create Secret resource"
)
})
.context("Dry-run create Secret")?;
self_api
.patch_status(
name,
&kube::api::PatchParams {
dry_run: true,
force: true,
field_manager: Some(env!("CARGO_PKG_NAME").into()),
field_validation: Some(kube::api::ValidationDirective::Strict),
},
&patch,
)
.await.inspect_err(|e| tracing::error!(debug=?e, reason="SelfUpdateFailed", action = "CreateNew", "Failed to update self status"))
.context("Dry-run update status")?;
}
// desperate run
{
let target = scopeguard::guard(
k8s_openapi::api::core::v1::Secret {
data: Some(T::generate_data(self).context("Create new Secret")?),
..partial_target
},
|mut v| zeroizers::zeroize_k8s_secret(&mut v),
);
target_api
.create(
&kube::api::PostParams {
dry_run: false,
field_manager: Some(env!("CARGO_PKG_NAME").into()),
},
&target,
)
.await
.inspect_err(|e| {
tracing::error!(
debug=?e,
reason = "SecretCreationFailed",
action = "CreateNew",
"Failed to create Secret resource"
)
})
.context("Create Secret")?;
if let Err(e) = self_api
.patch_status(
name,
&kube::api::PatchParams {
dry_run: false,
force: true,
field_manager: Some(env!("CARGO_PKG_NAME").into()),
field_validation: Some(kube::api::ValidationDirective::Strict),
},
&patch,
)
.await
.inspect_err(|e| tracing::error!(debug=?e, reason="SelfUpdateFailed", action = "CreateNew", "Failed to update self status"))
.context("Update status")
{
tracing::error!(
reason = "SecretOrphanCleanup",
action = "CreateNew",
"Deleting orphan secret after failed status update"
);
target_api.delete(name, &kube::api::DeleteParams::default()).await.context("Orphan secret on fail")?;
return Err(e);
}
}
Ok(())
}
async fn update_from_spec<T>(
&self,
name: &str,
namespace: &str,
client: kube::Client,
old_ref: &k8s_openapi::api::core::v1::Secret,
) -> anyhow::Result<()>
where
T: HasSecretGenerator
+ Resource<DynamicType = (), Scope = k8s_openapi::NamespaceResourceScope>
+ DeserializeOwned,
{
todo!("WIP")
}
}
pub(super) trait HasSecretGenerator {
fn generate_data(spec: &GeneratedSecretSpec) -> anyhow::Result<BTreeMap<String, k8s_openapi::ByteString>>;
}
#[cfg_attr(any(debug_assertions, test), derive(Debug, Eq, PartialEq))]
#[derive(Default, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GeneratedSecretTemplate {
/// Add custom metadata to generated secret
#[serde(default)]
pub metadata: GeneratedSecretMetadata,
/// Base64 (standard+pad) encoded data
#[serde(default)]
pub data: HashMap<String, String>,
/// Verbatim string data, which will be incoded as bytes into secret
#[serde(rename = "stringData")]
#[serde(default)]
pub string_data: BTreeMap<String, String>,
}
impl GeneratedSecretTemplate {
/// Does not retain data.
pub fn to_safe_partial_secret(
&self,
immutable: Option<bool>,
type_: Option<String>,
) -> k8s_openapi::api::core::v1::Secret {
k8s_openapi::api::core::v1::Secret {
type_,
immutable,
data: None,
metadata: self.metadata.clone().into_partial_metadata(),
string_data: None,
}
}
}
impl Zeroize for GeneratedSecretTemplate {
fn zeroize(&mut self) {
zeroize_btree(&mut self.string_data);
zeroize_hashmap(&mut self.data);
}
}
impl Drop for GeneratedSecretTemplate {
fn drop(&mut self) {
self.zeroize();
}
}
#[cfg_attr(any(debug_assertions, test), derive(Debug, Eq, PartialEq))]
#[derive(Default, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GeneratedSecretMetadata {
/// Add annotations to generated secret
#[serde(default)]
pub annotations: BTreeMap<String, String>,
/// Add labels to generated secret
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
impl GeneratedSecretMetadata {
pub fn into_partial_metadata(self) -> kube::api::ObjectMeta {
kube::api::ObjectMeta {
annotations: Some(self.annotations),
labels: Some(self.labels),
..Default::default()
}
}
}
#[cfg_attr(any(debug_assertions, test), derive(Debug, PartialEq, Eq))]
#[derive(Clone, Serialize, Deserialize, JsonSchema)]
pub struct GeneratedSecretKey {
/// The name of the key to write generated value into
pub key: String,
/// The version of generated value. Changing this will force new value to be generated
pub version: usize,
/// The type/kind specification for the generated value. May be `password` or `bytes`. Contains subfields
pub shape: GeneratedSecretShape,
}
#[cfg_attr(any(debug_assertions, test), derive(Debug, PartialEq, Eq))]
#[derive(Clone, Serialize, Deserialize, JsonSchema)]
pub enum GeneratedSecretShape {
Password {
/// The length of password in characters. Defaults to 32
#[serde(default = "GeneratedSecretShape::default_pwd_length")]
length: usize,
/// The string of characters generator allowed to use. Defaults to alphanumeric lowercase+uppercase
#[serde(default = "GeneratedSecretShape::default_pwd_dict")]
dictionary: String,
},
Bytes {
/// The size of secret in bytes. Defaults to 64
#[serde(default = "GeneratedSecretShape::default_bytes_size")]
size: usize,
},
}
impl GeneratedSecretShape {
/// Base64 encoded stable (blake3) hash
pub fn stable_hash(&self) -> String {
use base64::prelude::*;
let hash = {
let mut hasher = blake3::Hasher::new();
match self {
Self::Bytes { size } => {
hasher.update(b"bytes");
hasher.update(&size.to_le_bytes());
}
Self::Password { length, dictionary } => {
hasher.update(b"password");
hasher.update(&length.to_le_bytes());
hasher.update(dictionary.as_bytes());
}
}
hasher.finalize()
};
BASE64_STANDARD.encode(hash.as_bytes())
}
pub fn default_pwd_length() -> usize {
32
}
pub fn default_pwd_dict() -> String {
String::from("abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ")
}
pub fn default_bytes_size() -> usize {
64
}
}
impl Zeroize for GeneratedSecretShape {
fn zeroize(&mut self) {
match self {
Self::Bytes { size } => {
size.zeroize();
}
Self::Password { length, dictionary } => {
length.zeroize();
dictionary.zeroize();
}
}
}
}
impl Drop for GeneratedSecretShape {
fn drop(&mut self) {
self.zeroize();
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn deser_template_metadata() {
// Full sample
let sample = "
annotations:
my.custom-annotation: value
labels:
some-custom.label: value
";
let expect = GeneratedSecretMetadata {
annotations: BTreeMap::from_iter([("my.custom-annotation".into(), "value".into())]),
labels: BTreeMap::from_iter([("some-custom.label".into(), "value".into())]),
};
let parsed: GeneratedSecretMetadata = serde_yaml::from_str(sample).unwrap();
assert_eq!(parsed, expect, "Full sample didn't match");
// Empty annotations & labels
let sample = "
annotations: {}
labels: {}
";
let expect = GeneratedSecretMetadata {
annotations: BTreeMap::new(),
labels: BTreeMap::new(),
};
let parsed: GeneratedSecretMetadata = serde_yaml::from_str(sample).unwrap();
assert_eq!(parsed, expect, "Empty annotations&labels didn't match");
// Empty
let sample = "";
let expect = GeneratedSecretMetadata::default();
let parsed: GeneratedSecretMetadata = serde_yaml::from_str(sample).unwrap();
assert_eq!(parsed, expect, "Empty didn't match");
}
#[test]
fn deser_template() {
// full sample
let sample = "
data:
key0: abcde
stringData:
key1: fghij
metadata:
annotations:
my.custom-annotation: value
labels:
some-custom.label: value
";
let expect = GeneratedSecretTemplate {
data: HashMap::from_iter([("key0".into(), "abcde".into())]),
string_data: BTreeMap::from_iter([("key1".into(), "fghij".into())]),
metadata: GeneratedSecretMetadata {
annotations: BTreeMap::from_iter([("my.custom-annotation".into(), "value".into())]),
labels: BTreeMap::from_iter([("some-custom.label".into(), "value".into())]),
},
};
let parsed: GeneratedSecretTemplate = serde_yaml::from_str(sample).unwrap();
assert_eq!(parsed, expect, "Full sample didn't match");
// Empty metadata
let sample = "
data:
key0: abcde
stringData:
key1: fghij
metadata: {}
";
let expect = GeneratedSecretTemplate {
data: HashMap::from_iter([("key0".into(), "abcde".into())]),
string_data: BTreeMap::from_iter([("key1".into(), "fghij".into())]),
metadata: GeneratedSecretMetadata::default(),
};
let parsed: GeneratedSecretTemplate = serde_yaml::from_str(sample).unwrap();
assert_eq!(parsed, expect, "Empty metadata didn't match");
// No metadata
let sample = "
data:
key0: abcde
stringData:
key1: fghij
";
let expect = GeneratedSecretTemplate {
data: HashMap::from_iter([("key0".into(), "abcde".into())]),
string_data: BTreeMap::from_iter([("key1".into(), "fghij".into())]),
metadata: GeneratedSecretMetadata::default(),
};
let parsed: GeneratedSecretTemplate = serde_yaml::from_str(sample).unwrap();
assert_eq!(parsed, expect, "No metadata didn't match");
// Empty data & stringdata
let sample = "
data: {}
stringData: {}
";
let expect = GeneratedSecretTemplate {
data: HashMap::new(),
string_data: BTreeMap::new(),
metadata: GeneratedSecretMetadata::default(),
};
let parsed: GeneratedSecretTemplate = serde_yaml::from_str(sample).unwrap();
assert_eq!(parsed, expect, "Empty data&stringData didn't match");
// Empty
let sample = "";
let expect = GeneratedSecretTemplate::default();
let parsed: GeneratedSecretTemplate = serde_yaml::from_str(sample).unwrap();
assert_eq!(parsed, expect, "Empty didn't match");
}
#[test]
fn deser_key_spec() {
// Full password
let sample = "
key: aabbcc
version: 12
shape: !Password
length: 123
dictionary: abcdefg_1
";
let expect = GeneratedSecretKey {
key: "aabbcc".into(),
version: 12,
shape: GeneratedSecretShape::Password {
length: 123,
dictionary: "abcdefg_1".into(),
},
};
let parsed: GeneratedSecretKey = serde_yaml::from_str(sample).unwrap();
assert_eq!(expect, parsed, "Full password mismatch");
// Empty password
let sample = "
key: aabbcc
version: 12
shape: !Password
";
let expect = GeneratedSecretKey {
key: "aabbcc".into(),
version: 12,
shape: GeneratedSecretShape::Password {
length: GeneratedSecretShape::default_pwd_length(),
dictionary: GeneratedSecretShape::default_pwd_dict(),
},
};
let parsed: GeneratedSecretKey = serde_yaml::from_str(sample).unwrap();
assert_eq!(expect, parsed, "Empty password mismatch");
// Full bytes
let sample = "
key: aabbcc
version: 12
shape: !Bytes
size: 1234
";
let expect = GeneratedSecretKey {
key: "aabbcc".into(),
version: 12,
shape: GeneratedSecretShape::Bytes { size: 1234 },
};
let parsed: GeneratedSecretKey = serde_yaml::from_str(sample).unwrap();
assert_eq!(expect, parsed, "Full bytes mismatch");
// Empty bytes
let sample = "
key: aabbcc
version: 12
shape: !Bytes
";
let expect = GeneratedSecretKey {
key: "aabbcc".into(),
version: 12,
shape: GeneratedSecretShape::Bytes {
size: GeneratedSecretShape::default_bytes_size(),
},
};
let parsed: GeneratedSecretKey = serde_yaml::from_str(sample).unwrap();
assert_eq!(expect, parsed, "Empty bytes mismatch");
}
}

View file

@ -1,191 +0,0 @@
use std::collections::BTreeMap;
use anyhow::Context;
use k8s_openapi::ByteString;
use kube::runtime::controller::Action;
use kube::{
Client,
CustomResource,
Resource,
ResourceExt,
};
use rand::prelude::SeedableRng;
use rand_hc::Hc128Rng;
use schemars::JsonSchema;
use serde::{
Deserialize,
Serialize,
};
use crate::generator;
use crate::resource::generated::secret::{
GeneratedSecretShape,
GeneratedSecretSpec,
GeneratedSecretStatus,
HasSecretGenerator,
};
/// Spec to generate random v1/Secret resource.
/// The resource is generated with the same name in the same namespace as this CR is deployed.
///
/// The generated Secret data will change:
/// - On next reconcilation when:
/// - Generated v1/Secret was deleted (all regen)
/// - Key spec was changed (per-key regen)
///
/// Altering the `template` *will not* change generated values
#[derive(Debug, Clone, Serialize, Deserialize, CustomResource, JsonSchema)]
#[kube(
namespaced,
kind = "RandomSecret",
group = "generator.kuyaseg.spushk.ru",
version = "v1alpha1",
status = "GeneratedSecretStatus"
)]
#[kube(
singular = "randomsecret",
plural = "randomsecrets",
shortname = "randsec",
category = "all",
category = "generators",
category = "secret-generators"
)]
#[serde(transparent)]
pub struct RandomSecretSpec(GeneratedSecretSpec);
impl RandomSecret {
pub async fn reconcile(&self, client: Client) -> anyhow::Result<Action> {
let kind = Self::kind(&());
let api = Self::api_version(&());
let name = &self.name_any();
let _span_nons = tracing::info_span!(
"RandomSecret.reconcile",
k8s_kind = kind.as_ref(),
k8s_api = api.as_ref(),
k8s_name = name,
k8s_uid = self.uid(),
)
.entered();
let Some(namespace) = self.namespace() else {
tracing::error!(
reason = "RandomSecretNeedNamespace",
action = "Reconcile",
"RandomSecret must have namespace"
);
return Err(anyhow::Error::msg("RandomSecret needed namespace"));
};
let _span = tracing::info_span!("RandomSecret.reconcile.ns", k8s_ns = namespace).entered();
self.spec.0.reconcile_spec::<Self>(name, &namespace, client, self.uid().as_deref()).await.inspect_err(|e| tracing::error!(debug=?e, reason="RandomSecretReconciliationFailed", action = "Reconcile", "Failed to reconcile RandomSecret {namespace}{name}")).context("Reconcile")?;
// TODO: verify if all desired events fire
Ok(Action::await_change())
}
}
impl HasSecretGenerator for RandomSecret {
fn generate_data(spec: &GeneratedSecretSpec) -> anyhow::Result<BTreeMap<String, ByteString>> {
let mut rng = generator::AwsRng::new();
let mut data = BTreeMap::new();
for key in spec.keys.iter() {
match &key.shape {
GeneratedSecretShape::Bytes { size } => {
data.insert(key.key.clone(), ByteString(generator::bytes(&mut rng, *size)));
}
GeneratedSecretShape::Password { length, dictionary } => {
data.insert(key.key.clone(), ByteString(generator::str_bytes(&mut rng, *length, dictionary.chars()).inspect_err(|e| tracing::error!(debug=?e, reason="PasswordGenFailed", action="Generate", "Failed to generate new random password")).context("Generate password")?));
}
}
}
Ok(data)
}
}
/// Spec to generate DETERMINISTIC v1/Secret resource.
/// The resource is generated with the same name in the same namespace as this CR is deployed.
///
/// The generated Secret data will change on next reconcilation when key spec was changed (per-key regen)
///
/// Altering the `template` *will not* change generated values.
/// Removing generated v1/Secret *will not* change generated values.
#[derive(Debug, Clone, Serialize, Deserialize, CustomResource, JsonSchema)]
#[kube(
namespaced,
kind = "DeterministicSecret",
group = "generator.kuyaseg.spushk.ru",
version = "v1alpha1",
status = "GeneratedSecretStatus"
)]
#[kube(
singular = "deterministicsecret",
plural = "deterministicsecrets",
shortname = "detsec",
category = "all",
category = "generators",
category = "secret-generators"
)]
#[serde(transparent)]
pub struct DeterministicSecretSpec(GeneratedSecretSpec);
impl DeterministicSecret {
pub async fn reconcile(&self, client: Client) -> anyhow::Result<Action> {
let kind = Self::kind(&());
let api = Self::api_version(&());
let name = &self.name_any();
let _span_nons = tracing::info_span!(
"DeterministicSecret.reconcile",
k8s_kind = kind.as_ref(),
k8s_api = api.as_ref(),
k8s_name = name,
k8s_uid = self.uid(),
)
.entered();
let Some(namespace) = self.namespace() else {
tracing::error!(
reason = "DeterministicSecretNeedNamespace",
action = "Reconcile",
"DeterministicSecret must have namespace"
);
return Err(anyhow::Error::msg("DeterministicSecret needed namespace"));
};
let _span = tracing::info_span!("DeterministicSecret.reconcile.ns", k8s_ns = namespace).entered();
self.spec.0.reconcile_spec::<Self>(name, &namespace, client, self.uid().as_deref()).await.inspect_err(|e| tracing::error!(debug=?e, reason="DeterministicSecretReconciliationFailed", action = "Reconcile", "Failed to reconcile DeterministicSecret {namespace}{name}")).context("Reconcile")?;
// TODO: verify if all desired events fire
Ok(Action::await_change())
}
}
impl HasSecretGenerator for DeterministicSecret {
fn generate_data(spec: &GeneratedSecretSpec) -> anyhow::Result<BTreeMap<String, ByteString>> {
let seed = std::env::var("KUYASEG_SEED").inspect_err(|e| tracing::error!(debug=?e, reason="DeterministicSecretNeedSeed", "Refuse to generate deterministic secret without seed. Set with KUYASEG_SEED env")).context("Get external seed")?;
let mut data = BTreeMap::new();
for key in spec.keys.iter() {
let mut rng = {
let mut hasher = blake3::Hasher::new();
hasher.update(seed.as_bytes());
hasher.update(key.key.as_bytes());
hasher.update(&key.version.to_le_bytes());
hasher.update(key.shape.stable_hash().as_bytes());
Hc128Rng::from_seed(hasher.finalize().into())
};
match &key.shape {
GeneratedSecretShape::Bytes { size } => {
data.insert(key.key.clone(), ByteString(generator::bytes(&mut rng, *size)));
}
GeneratedSecretShape::Password { length, dictionary } => {
data.insert(key.key.clone(), ByteString(generator::str_bytes(&mut rng, *length, dictionary.chars()).inspect_err(|e| tracing::error!(debug=?e, reason="PasswordGenFailed", action="Generate", "Failed to generate new random password")).context("Generate password")?));
}
}
}
Ok(data)
}
}

View file

@ -0,0 +1,354 @@
use std::collections::{
BTreeMap,
HashMap,
};
use k8s_openapi::ByteString;
use schemars::JsonSchema;
use serde::{
Deserialize,
Serialize,
};
use serde_with::serde_as;
use zeroize::Zeroize;
use crate::utils::{
serializers,
zeroizers,
};
mod pseudorandom;
pub use pseudorandom::*;
mod random;
pub use random::*;
#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SecretStatus {
pub key_tracker: HashMap<String, KeyStatus>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde_as]
pub struct KeyStatus {
/// Version is tracked separately, for change must set NE status
pub version: usize,
#[schemars(schema_with = "String::json_schema")]
#[serde_as(as = "serde_with::hex::Hex<serde_with::formats::Uppercase>")]
pub spec_hash: blake3::Hash,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(test, derive(PartialEq))]
pub struct KeySpec {
/// Name of the key to put generated data into
pub key: String,
/// Tracks the key generation version within the same shape.
/// Increasing this number will force value regeneration on next reconcilation
pub version: usize,
/// Determines the shape of generated data
pub shape: KeyShape,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(test, derive(PartialEq))]
pub enum KeyShape {
/// Generate arbitrary bytes
Bytes {
/// Size of generated sequence in bytes.
///
/// Defaults to 64
#[serde(default = "KeyShape::default_bytes_size")]
size: usize,
},
/// Generate string password
Password {
/// The length of password in characters.
///
/// Defaults to 24
#[serde(default = "KeyShape::default_pwd_length")]
length: usize,
/// The string of characters allowed to be present in password.
/// When dictionary length is > 4, each character in generated password is guaranteed to repeat
/// with period at least 3.
///
/// Defaults to lowercase+uppercase alphanumeric and `-`
#[serde(default = "KeyShape::default_pwd_dict")]
dictionary: String,
},
}
impl KeyShape {
/// BLAKE3 hash, incorporating shape and according properties
pub fn stable_hash(&self) -> blake3::Hash {
let mut hasher = blake3::Hasher::new();
match self {
Self::Bytes { size } => {
hasher.update(b"bytes");
hasher.update(&size.to_le_bytes());
}
Self::Password { length, dictionary } => {
hasher.update(b"password");
hasher.update(&length.to_le_bytes());
hasher.update(dictionary.as_bytes());
}
}
hasher.finalize()
}
pub fn default_bytes_size() -> usize {
64
}
pub fn default_pwd_length() -> usize {
24
}
pub fn default_pwd_dict() -> String {
String::from("abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-")
}
}
impl Zeroize for KeyShape {
#[inline]
fn zeroize(&mut self) {
match self {
Self::Bytes { size } => size.zeroize(),
Self::Password { length, dictionary } => {
length.zeroize();
dictionary.zeroize();
}
}
}
}
impl Drop for KeyShape {
fn drop(&mut self) {
self.zeroize();
}
}
#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(test, derive(PartialEq))]
pub struct SecretTemplate {
/// Define created Secret resource metadata
#[serde(default)]
pub metadata: SecretMetadata,
/// Include static data into created Secret
#[serde(default)]
#[schemars(schema_with = "BTreeMap::<String, String>::json_schema")]
pub data: BTreeMap<String, ByteString>,
/// Include static string data into created Secret
#[serde(default)]
#[serde(rename = "stringData")]
#[serde(with = "serializers::btreemap_to_bytestring_inplace")]
#[schemars(schema_with = "BTreeMap::<String, String>::json_schema")]
pub string_data: BTreeMap<String, ByteString>,
}
impl Zeroize for SecretTemplate {
#[inline]
fn zeroize(&mut self) {
zeroizers::zeroize_btree_of_bytestring(&mut self.data);
zeroizers::zeroize_btree_of_bytestring(&mut self.string_data);
}
}
impl Drop for SecretTemplate {
fn drop(&mut self) {
self.zeroize();
}
}
#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(test, derive(PartialEq))]
pub struct SecretMetadata {
#[serde(default)]
pub annotations: BTreeMap<String, String>,
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn deser_template_metadata() {
// Full sample
let sample = "
annotations:
my.custom-annotation: value
labels:
some-custom.label: value
";
let expect = SecretMetadata {
annotations: BTreeMap::from_iter([("my.custom-annotation".into(), "value".into())]),
labels: BTreeMap::from_iter([("some-custom.label".into(), "value".into())]),
};
let parsed: SecretMetadata = serde_yaml::from_str(sample).unwrap();
assert_eq!(parsed, expect, "Full sample didn't match");
// Empty annotations & labels
let sample = "
annotations: {}
labels: {}
";
let expect = SecretMetadata {
annotations: BTreeMap::new(),
labels: BTreeMap::new(),
};
let parsed: SecretMetadata = serde_yaml::from_str(sample).unwrap();
assert_eq!(parsed, expect, "Empty annotations&labels didn't match");
// Empty
let sample = "";
let expect = SecretMetadata::default();
let parsed: SecretMetadata = serde_yaml::from_str(sample).unwrap();
assert_eq!(parsed, expect, "Empty didn't match");
}
#[test]
fn deser_template() {
// full sample
let sample = "
data:
key0: YWJjZGU=
stringData:
key1: fghij
metadata:
annotations:
my.custom-annotation: value
labels:
some-custom.label: value
";
let expect = SecretTemplate {
data: BTreeMap::from_iter([("key0".into(), ByteString("abcde".to_string().into_bytes()))]),
string_data: BTreeMap::from_iter([("key1".into(), ByteString("fghij".to_string().into_bytes()))]),
metadata: SecretMetadata {
annotations: BTreeMap::from_iter([("my.custom-annotation".into(), "value".into())]),
labels: BTreeMap::from_iter([("some-custom.label".into(), "value".into())]),
},
};
let parsed: SecretTemplate = serde_yaml::from_str(sample).unwrap();
assert_eq!(parsed, expect, "Full sample didn't match");
// Empty metadata
let sample = "
data:
key0: YWJjZGU=
stringData:
key1: fghij
metadata: {}
";
let expect = SecretTemplate {
data: BTreeMap::from_iter([("key0".into(), ByteString("abcde".to_string().into_bytes()))]),
string_data: BTreeMap::from_iter([("key1".into(), ByteString("fghij".to_string().into_bytes()))]),
metadata: SecretMetadata::default(),
};
let parsed: SecretTemplate = serde_yaml::from_str(sample).unwrap();
assert_eq!(parsed, expect, "Empty metadata didn't match");
// No metadata
let sample = "
data:
key0: YWJjZGU=
stringData:
key1: fghij
";
let expect = SecretTemplate {
data: BTreeMap::from_iter([("key0".into(), ByteString("abcde".to_string().into_bytes()))]),
string_data: BTreeMap::from_iter([("key1".into(), ByteString("fghij".to_string().into_bytes()))]),
metadata: SecretMetadata::default(),
};
let parsed: SecretTemplate = serde_yaml::from_str(sample).unwrap();
assert_eq!(parsed, expect, "No metadata didn't match");
// Empty data & stringdata
let sample = "
data: {}
stringData: {}
";
let expect = SecretTemplate {
data: BTreeMap::new(),
string_data: BTreeMap::new(),
metadata: SecretMetadata::default(),
};
let parsed: SecretTemplate = serde_yaml::from_str(sample).unwrap();
assert_eq!(parsed, expect, "Empty data&stringData didn't match");
// Empty
let sample = "";
let expect = SecretTemplate::default();
let parsed: SecretTemplate = serde_yaml::from_str(sample).unwrap();
assert_eq!(parsed, expect, "Empty didn't match");
}
#[test]
fn deser_key_spec() {
// Full password
let sample = "
key: aabbcc
version: 12
shape: !Password
length: 123
dictionary: abcdefg_1
";
let expect = KeySpec {
key: "aabbcc".into(),
version: 12,
shape: KeyShape::Password {
length: 123,
dictionary: "abcdefg_1".into(),
},
};
let parsed: KeySpec = serde_yaml::from_str(sample).unwrap();
assert_eq!(expect, parsed, "Full password mismatch");
// Empty password
let sample = "
key: aabbcc
version: 12
shape: !Password
";
let expect = KeySpec {
key: "aabbcc".into(),
version: 12,
shape: KeyShape::Password {
length: KeyShape::default_pwd_length(),
dictionary: KeyShape::default_pwd_dict(),
},
};
let parsed: KeySpec = serde_yaml::from_str(sample).unwrap();
assert_eq!(expect, parsed, "Empty password mismatch");
// Full bytes
let sample = "
key: aabbcc
version: 12
shape: !Bytes
size: 1234
";
let expect = KeySpec {
key: "aabbcc".into(),
version: 12,
shape: KeyShape::Bytes { size: 1234 },
};
let parsed: KeySpec = serde_yaml::from_str(sample).unwrap();
assert_eq!(expect, parsed, "Full bytes mismatch");
// Empty bytes
let sample = "
key: aabbcc
version: 12
shape: !Bytes
";
let expect = KeySpec {
key: "aabbcc".into(),
version: 12,
shape: KeyShape::Bytes {
size: KeyShape::default_bytes_size(),
},
};
let parsed: KeySpec = serde_yaml::from_str(sample).unwrap();
assert_eq!(expect, parsed, "Empty bytes mismatch");
}
}

View file

@ -1 +1 @@
pub type GeneratedSshSecretTemplate = super::secret::GeneratedSecretTemplate;

View file

@ -1 +1,2 @@
pub mod serializers;
pub mod zeroizers;

View file

@ -0,0 +1,106 @@
/// in-place conversion of String into ByteString, verbatim, utf8-lossy
///
/// e.g. `"abc" -> ByteString("abc".to_string().into_bytes())`
pub mod str_to_bytestring_inplace {
use k8s_openapi::ByteString;
use serde::de::Visitor;
use serde::{
Deserializer,
Serializer,
};
pub fn serialize<S>(value: &ByteString, se: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let value = String::from_utf8_lossy(&value.0);
se.serialize_str(&value)
}
pub fn deserialize<'de, D>(de: D) -> Result<ByteString, D::Error>
where
D: Deserializer<'de>,
{
de.deserialize_str(StringBytestringVisitor)
}
struct StringBytestringVisitor;
impl<'de> Visitor<'de> for StringBytestringVisitor {
type Value = ByteString;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "string")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(ByteString(v.as_bytes().into()))
}
}
}
/// in-place conversion of BTreeMap<K, String> to BTreeMap<K, ByteString>, verbatim, utf8-lossy
///
///e.g. `{"abc": "def"} -> {"abc": ByteString("def".to_string().into_bytes())}`
pub mod btreemap_to_bytestring_inplace {
use std::collections::BTreeMap;
use std::marker::PhantomData;
use k8s_openapi::ByteString;
use serde::de::Visitor;
use serde::ser::SerializeMap;
use serde::{
Deserialize,
Deserializer,
Serialize,
Serializer,
};
pub fn serialize<S, K>(value: &BTreeMap<K, ByteString>, se: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
K: Serialize,
{
let mut map = se.serialize_map(Some(value.len()))?;
value
.iter()
.try_for_each(|(k, v)| map.serialize_entry(k, &String::from_utf8_lossy(&v.0)))?;
map.end()
}
pub fn deserialize<'de, K, D>(de: D) -> Result<BTreeMap<K, ByteString>, D::Error>
where
D: Deserializer<'de>,
K: Ord + Deserialize<'de>,
{
de.deserialize_map(BTreeVisitor(PhantomData))
}
struct BTreeVisitor<K>(PhantomData<K>);
impl<'de, K: Ord + Deserialize<'de>> Visitor<'de> for BTreeVisitor<K> {
type Value = BTreeMap<K, ByteString>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "map {ty} -> String", ty = std::any::type_name::<K>())
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
let mut btree = BTreeMap::new();
while let Some((key, value)) = map.next_entry::<K, &str>()? {
if btree.contains_key(&key) {
return Err(serde::de::Error::custom("Duplicate key"));
}
btree.insert(key, ByteString(value.as_bytes().into()));
}
Ok(btree)
}
}
}