feat(controller): introduce basic, UNREADY generation

This commit is contained in:
spushk 2026-05-16 13:43:15 +05:00 • committed by spushk
commit 1e7d71ffc3
Signed by: spushk
GPG key ID: 7797B761E105E8AA
9 changed files with 780 additions and 1 deletions

1
Cargo.lock generated
View file

@ -1599,6 +1599,7 @@ dependencies = [
"ssh-key",
"thiserror",
"tokio",
"tokio-util",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",

View file

@ -18,6 +18,7 @@ mimalloc.version = "^0.1"
mimalloc.features = ["secure"]
tokio.version = "^1"
tokio.features = ["macros", "rt-multi-thread"]
tokio-util.version = '^0.7'
axum.version = "^0.8"
## Tracing
tracing.version = "^0.1"

View file

@ -18,6 +18,7 @@ path = "src/bin/generate_crds.rs"
## Rt
mimalloc.workspace = true
tokio.workspace = true
tokio-util.workspace = true
axum.workspace = true
## Tracing
tracing.workspace = true

View file

@ -1 +1,14 @@
fn main() {}
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

@ -0,0 +1,2 @@
pub mod secret;
pub mod ssh;

View file

@ -0,0 +1,577 @@
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)
&& aws_lc_rs::constant_time::verify_slices_are_equal(
stored.1.as_bytes(),
spec.shape.stable_hash().as_bytes(),
)
.is_ok()
}
}
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) -> 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)
.await
.context("Update existing Secret")?,
}
Ok(())
}
async fn create_new_from_spec<T>(&self, name: &str, namespace: &str, client: kube::Client) -> anyhow::Result<()>
where
T: HasSecretGenerator
+ Resource<DynamicType = (), Scope = k8s_openapi::NamespaceResourceScope>
+ DeserializeOwned,
{
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 partial_target = self.template.to_safe_partial_secret(Some(false), Some("Opaque".into()));
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")?;
// TODO: delete created secret if this fails or not..?
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")?;
}
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(&size.to_le_bytes());
}
Self::Password { length, dictionary } => {
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

@ -0,0 +1,182 @@
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", kind = kind.as_ref(), api = api.as_ref(), name).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", ns = namespace).entered();
self.spec.0.reconcile_spec::<Self>(name, &namespace, client).await.inspect_err(|e| tracing::error!(debug=?e, reason="RandomSecretReconcilationFailed", action = "Reconcile", "Failed to reconcile RandomSecret {namespace}{name}")).context("Reconcile")?;
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",
kind = kind.as_ref(),
api = api.as_ref(),
name
)
.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", ns = namespace).entered();
self.spec.0.reconcile_spec::<Self>(name, &namespace, client).await.inspect_err(|e| tracing::error!(debug=?e, reason="DeterministicSecretReconcilationFailed", action = "Reconcile", "Failed to reconcile DeterministicSecret {namespace}{name}")).context("Reconcile")?;
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 seet. 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 @@
pub type GeneratedSshSecretTemplate = super::secret::GeneratedSecretTemplate;

View file

@ -0,0 +1 @@
pub mod generated;