feat(controller): introduce basic, UNREADY generation
This commit is contained in:
parent
69ccd48c3b
commit
1e7d71ffc3
6 changed files with 780 additions and 1 deletions
|
|
@ -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")
|
||||
)
|
||||
}
|
||||
|
|
|
|||
2
kuyaseg-controller/src/resource/generated/mod.rs
Normal file
2
kuyaseg-controller/src/resource/generated/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod secret;
|
||||
pub mod ssh;
|
||||
577
kuyaseg-controller/src/resource/generated/secret/mod.rs
Normal file
577
kuyaseg-controller/src/resource/generated/secret/mod.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
182
kuyaseg-controller/src/resource/generated/secret/v1alpha1.rs
Normal file
182
kuyaseg-controller/src/resource/generated/secret/v1alpha1.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
1
kuyaseg-controller/src/resource/generated/ssh/mod.rs
Normal file
1
kuyaseg-controller/src/resource/generated/ssh/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub type GeneratedSshSecretTemplate = super::secret::GeneratedSecretTemplate;
|
||||
1
kuyaseg-controller/src/resource/mod.rs
Normal file
1
kuyaseg-controller/src/resource/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod generated;
|
||||
Loading…
Reference in a new issue