diff --git a/Cargo.lock b/Cargo.lock index 42c4cd2..bab0702 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -378,7 +378,7 @@ dependencies = [ "serde_json", "serde_repr", "serde_urlencoded", - "thiserror 2.0.12", + "thiserror 2.0.14", "tokio", "tokio-util", "tower-service", @@ -473,7 +473,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.14", ] [[package]] @@ -515,6 +515,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.9.1" @@ -1689,9 +1695,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", ] [[package]] @@ -1789,6 +1797,7 @@ dependencies = [ "env_logger", "fqdn", "futures-util", + "harmony-secret-derive", "harmony_macros", "harmony_types", "helm-wrapper-rs", @@ -1829,6 +1838,35 @@ dependencies = [ "uuid", ] +[[package]] +name = "harmony-secret" +version = "0.1.0" +dependencies = [ + "async-trait", + "directories", + "harmony-secret-derive", + "http 1.3.1", + "infisical", + "lazy_static", + "log", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.14", + "tokio", +] + +[[package]] +name = "harmony-secret-derive" +version = "0.1.0" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "harmony_cli" version = "0.1.0" @@ -1963,7 +2001,7 @@ dependencies = [ "non-blank-string-rs", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.14", ] [[package]] @@ -2131,7 +2169,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -2210,6 +2248,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -2272,7 +2311,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.5.10", "system-configuration 0.6.1", "tokio", "tower-service", @@ -2489,6 +2528,21 @@ version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +[[package]] +name = "infisical" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d97c33b08e22b2f7b9f87a8fc06a7d247442db7bf216ffc6661a74ed8aea658" +dependencies = [ + "base64 0.22.1", + "reqwest 0.12.20", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "url", +] + [[package]] name = "inout" version = "0.1.4" @@ -2529,6 +2583,17 @@ dependencies = [ "syn", ] +[[package]] +name = "io-uring" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2622,7 +2687,7 @@ dependencies = [ "pest_derive", "regex", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.14", ] [[package]] @@ -2723,7 +2788,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "thiserror 2.0.12", + "thiserror 2.0.14", "tokio", "tokio-tungstenite", "tokio-util", @@ -2748,7 +2813,7 @@ dependencies = [ "serde", "serde-value", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.14", ] [[package]] @@ -2786,7 +2851,7 @@ dependencies = [ "pin-project", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.14", "tokio", "tokio-util", "tracing", @@ -2898,6 +2963,12 @@ dependencies = [ "hashbrown 0.15.4", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "md5" version = "0.7.0" @@ -3200,7 +3271,7 @@ dependencies = [ "pretty_assertions", "rand 0.8.5", "serde", - "thiserror 1.0.69", + "thiserror 2.0.14", "tokio", "uuid", "xml-rs", @@ -3367,7 +3438,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" dependencies = [ "memchr", - "thiserror 2.0.12", + "thiserror 2.0.14", "ucd-trie", ] @@ -3588,6 +3659,15 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-crate" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -3603,6 +3683,61 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9e1dcb320d6839f6edb64f7a4a59d39b30480d4d1765b56873f7c858538a5fe" +[[package]] +name = "quinn" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.5.10", + "thiserror 2.0.14", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.1", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.14", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.5.10", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.40" @@ -3721,7 +3856,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.12", + "thiserror 2.0.14", ] [[package]] @@ -3822,6 +3957,7 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2 0.4.10", @@ -3838,6 +3974,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -3845,6 +3983,7 @@ dependencies = [ "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3854,6 +3993,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots", ] [[package]] @@ -4017,7 +4157,7 @@ dependencies = [ "flurry", "log", "serde", - "thiserror 2.0.12", + "thiserror 2.0.14", "tokio", "tokio-util", ] @@ -4043,6 +4183,12 @@ version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -4142,6 +4288,7 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ + "web-time", "zeroize", ] @@ -4580,7 +4727,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.12", + "thiserror 2.0.14", "time", ] @@ -4627,6 +4774,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "spin" version = "0.9.8" @@ -4764,9 +4921,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.104" +version = "2.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "7bc3fcb250e53458e712715cf74285c1f889686520d79294a9ef3bd7aa1fc619" dependencies = [ "proc-macro2", "quote", @@ -4900,11 +5057,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "0b0949c3a6c842cbde3f1686d6eea5a010516deb7085f79db747562d4102f41e" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.14", ] [[package]] @@ -4920,9 +5077,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "cc5b44b4ab9c2fdd0e0512e6bece8388e214c0749f5862b114cc5b7a25daf227" dependencies = [ "proc-macro2", "quote", @@ -4989,20 +5146,38 @@ dependencies = [ ] [[package]] -name = "tokio" -version = "1.45.1" +name = "tinyvec" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", + "io-uring", "libc", "mio 1.0.4", + "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "slab", + "socket2 0.6.0", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5252,7 +5427,7 @@ dependencies = [ "log", "rand 0.9.1", "sha1", - "thiserror 2.0.12", + "thiserror 2.0.14", "utf-8", ] @@ -5553,6 +5728,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index b12a4b5..a91bf56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,8 @@ members = [ "harmony_cli", "k3d", "harmony_composer", + "harmony_secret_derive", + "harmony_secret", ] [workspace.package] @@ -53,6 +55,10 @@ chrono = "0.4" similar = "2" uuid = { version = "1.11", features = ["v4", "fast-rng", "macro-diagnostics"] } pretty_assertions = "1.4.1" +tempfile = "3.20.0" bollard = "0.19.1" base64 = "0.22.1" tar = "0.4.44" +lazy_static = "1.5.0" +directories = "6.0.0" +thiserror = "2.0.14" diff --git a/harmony/Cargo.toml b/harmony/Cargo.toml index 5a42cf7..abbdfb3 100644 --- a/harmony/Cargo.toml +++ b/harmony/Cargo.toml @@ -38,8 +38,8 @@ serde-value.workspace = true helm-wrapper-rs = "0.4.0" non-blank-string-rs = "1.0.4" k3d-rs = { path = "../k3d" } -directories = "6.0.0" -lazy_static = "1.5.0" +directories.workspace = true +lazy_static.workspace = true dockerfile_builder = "0.1.5" temp-file = "0.1.9" convert_case.workspace = true @@ -59,7 +59,7 @@ similar.workspace = true futures-util = "0.3.31" tokio-util = "0.7.15" strum = { version = "0.27.1", features = ["derive"] } -tempfile = "3.20.0" +tempfile.workspace = true serde_with = "3.14.0" schemars = "0.8.22" kube-derive = "1.1.0" @@ -67,6 +67,7 @@ bollard.workspace = true tar.workspace = true base64.workspace = true once_cell = "1.21.3" +harmony-secret-derive = { version = "0.1.0", path = "../harmony_secret_derive" } [dev-dependencies] pretty_assertions.workspace = true diff --git a/harmony/harmony.rlib b/harmony/harmony.rlib new file mode 100644 index 0000000..feb8a05 Binary files /dev/null and b/harmony/harmony.rlib differ diff --git a/harmony_secret/Cargo.toml b/harmony_secret/Cargo.toml new file mode 100644 index 0000000..48c8f5c --- /dev/null +++ b/harmony_secret/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "harmony-secret" +edition = "2024" +version.workspace = true +readme.workspace = true +license.workspace = true + +[dependencies] +harmony-secret-derive = { version = "0.1.0", path = "../harmony_secret_derive" } +serde = { version = "1.0.209", features = ["derive", "rc"] } +serde_json = "1.0.127" +thiserror.workspace = true +lazy_static.workspace = true +directories.workspace = true +log.workspace = true +infisical = "0.0.2" +tokio.workspace = true +async-trait.workspace = true +http.workspace = true + +[dev-dependencies] +pretty_assertions.workspace = true +tempfile.workspace = true diff --git a/harmony_secret/src/config.rs b/harmony_secret/src/config.rs new file mode 100644 index 0000000..54494bf --- /dev/null +++ b/harmony_secret/src/config.rs @@ -0,0 +1,18 @@ +use lazy_static::lazy_static; + +lazy_static! { + pub static ref SECRET_NAMESPACE: String = + std::env::var("HARMONY_SECRET_NAMESPACE").expect("HARMONY_SECRET_NAMESPACE environment variable is required, it should contain the name of the project you are working on to access its secrets"); + pub static ref SECRET_STORE: Option = + std::env::var("HARMONY_SECRET_STORE").ok(); + pub static ref INFISICAL_URL: Option = + std::env::var("HARMONY_SECRET_INFISICAL_URL").ok(); + pub static ref INFISICAL_PROJECT_ID: Option = + std::env::var("HARMONY_SECRET_INFISICAL_PROJECT_ID").ok(); + pub static ref INFISICAL_ENVIRONMENT: Option = + std::env::var("HARMONY_SECRET_INFISICAL_ENVIRONMENT").ok(); + pub static ref INFISICAL_CLIENT_ID: Option = + std::env::var("HARMONY_SECRET_INFISICAL_CLIENT_ID").ok(); + pub static ref INFISICAL_CLIENT_SECRET: Option = + std::env::var("HARMONY_SECRET_INFISICAL_CLIENT_SECRET").ok(); +} diff --git a/harmony_secret/src/lib.rs b/harmony_secret/src/lib.rs new file mode 100644 index 0000000..a12fb8e --- /dev/null +++ b/harmony_secret/src/lib.rs @@ -0,0 +1,166 @@ +pub mod config; +mod store; + +use crate::config::SECRET_NAMESPACE; +use async_trait::async_trait; +use config::INFISICAL_CLIENT_ID; +use config::INFISICAL_CLIENT_SECRET; +use config::INFISICAL_ENVIRONMENT; +use config::INFISICAL_PROJECT_ID; +use config::INFISICAL_URL; +use config::SECRET_STORE; +use serde::{Serialize, de::DeserializeOwned}; +use std::fmt; +use store::InfisicalSecretStore; +use store::LocalFileSecretStore; +use thiserror::Error; +use tokio::sync::OnceCell; + +pub use harmony_secret_derive::Secret; + +// The Secret trait remains the same. +pub trait Secret: Serialize + DeserializeOwned + Sized { + const KEY: &'static str; +} + +// The error enum remains the same. +#[derive(Debug, Error)] +pub enum SecretStoreError { + #[error("Secret not found for key '{key}' in namespace '{namespace}'")] + NotFound { namespace: String, key: String }, + #[error("Failed to deserialize secret for key '{key}': {source}")] + Deserialization { + key: String, + source: serde_json::Error, + }, + #[error("Failed to serialize secret for key '{key}': {source}")] + Serialization { + key: String, + source: serde_json::Error, + }, + #[error("Underlying storage error: {0}")] + Store(#[from] Box), +} + +// The trait is now async! +#[async_trait] +pub trait SecretStore: fmt::Debug + Send + Sync { + async fn get_raw(&self, namespace: &str, key: &str) -> Result, SecretStoreError>; + async fn set_raw( + &self, + namespace: &str, + key: &str, + value: &[u8], + ) -> Result<(), SecretStoreError>; +} + +// Use OnceCell for async-friendly, one-time initialization. +static SECRET_MANAGER: OnceCell = OnceCell::const_new(); + +/// Initializes and returns a reference to the global SecretManager. +async fn get_secret_manager() -> &'static SecretManager { + SECRET_MANAGER.get_or_init(init_secret_manager).await +} + +/// The async initialization function for the SecretManager. +async fn init_secret_manager() -> SecretManager { + let default_secret_score = "infisical".to_string(); + let store_type = SECRET_STORE.as_ref().unwrap_or(&default_secret_score); + + let store: Box = match store_type.as_str() { + "file" => Box::new(LocalFileSecretStore::default()), + "infisical" | _ => { + let store = InfisicalSecretStore::new( + INFISICAL_URL.clone().expect("Infisical url must be set, see harmony_secret config for ways to provide it. You can try with HARMONY_SECRET_INFISICAL_URL"), + INFISICAL_PROJECT_ID.clone().expect("Infisical project id must be set, see harmony_secret config for ways to provide it. You can try with HARMONY_SECRET_INFISICAL_PROJECT_ID"), + INFISICAL_ENVIRONMENT.clone().expect("Infisical environment must be set, see harmony_secret config for ways to provide it. You can try with HARMONY_SECRET_INFISICAL_ENVIRONMENT"), + INFISICAL_CLIENT_ID.clone().expect("Infisical client id must be set, see harmony_secret config for ways to provide it. You can try with HARMONY_SECRET_INFISICAL_CLIENT_ID"), + INFISICAL_CLIENT_SECRET.clone().expect("Infisical client secret must be set, see harmony_secret config for ways to provide it. You can try with HARMONY_SECRET_INFISICAL_CLIENT_SECRET"), + ) + .await + .expect("Failed to initialize Infisical secret store"); + Box::new(store) + } + }; + + SecretManager::new(SECRET_NAMESPACE.clone(), store) +} + +/// Manages the lifecycle of secrets, providing a simple static API. +#[derive(Debug)] +pub struct SecretManager { + namespace: String, + store: Box, +} + +impl SecretManager { + fn new(namespace: String, store: Box) -> Self { + Self { namespace, store } + } + + /// Retrieves and deserializes a secret. + pub async fn get() -> Result { + let manager = get_secret_manager().await; + let raw_value = manager.store.get_raw(&manager.namespace, T::KEY).await?; + serde_json::from_slice(&raw_value).map_err(|e| SecretStoreError::Deserialization { + key: T::KEY.to_string(), + source: e, + }) + } + + /// Serializes and stores a secret. + pub async fn set(secret: &T) -> Result<(), SecretStoreError> { + let manager = get_secret_manager().await; + let raw_value = + serde_json::to_vec(secret).map_err(|e| SecretStoreError::Serialization { + key: T::KEY.to_string(), + source: e, + })?; + manager + .store + .set_raw(&manager.namespace, T::KEY, &raw_value) + .await + } +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + use serde::{Deserialize, Serialize}; + + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct TestUserMeta { + labels: Vec, + } + + #[derive(Secret, Serialize, Deserialize, Debug, PartialEq)] + struct TestSecret { + user: String, + password: String, + metadata: TestUserMeta, + } + + #[cfg(secrete2etest)] + #[tokio::test] + async fn set_and_retrieve_secret() { + let secret = TestSecret { + user: String::from("user"), + password: String::from("password"), + metadata: TestUserMeta { + labels: vec![ + String::from("label1"), + String::from("label2"), + String::from( + "some longet label with \" special @#%$)(udiojcia[]]] \"'asdij'' characters Nдs はにほへとちり าฟันพัฒนา yağız şoföre ç � � � � � � � � � � � � � 👩‍👩‍👧‍👦 /span> 👩‍👧‍👦 and why not emojis ", + ), + ], + }, + }; + + SecretManager::set(&secret).await.unwrap(); + let value = SecretManager::get::().await.unwrap(); + + assert_eq!(value, secret); + } +} diff --git a/harmony_secret/src/store/infisical.rs b/harmony_secret/src/store/infisical.rs new file mode 100644 index 0000000..ff8e14f --- /dev/null +++ b/harmony_secret/src/store/infisical.rs @@ -0,0 +1,129 @@ +use crate::{SecretStore, SecretStoreError}; +use async_trait::async_trait; +use infisical::{ + AuthMethod, InfisicalError, + client::Client, + secrets::{CreateSecretRequest, GetSecretRequest, UpdateSecretRequest}, +}; +use log::{info, warn}; + +#[derive(Debug)] +pub struct InfisicalSecretStore { + client: Client, + project_id: String, + environment: String, +} + +impl InfisicalSecretStore { + /// Creates a new, authenticated Infisical client. + pub async fn new( + base_url: String, + project_id: String, + environment: String, + client_id: String, + client_secret: String, + ) -> Result { + info!("INFISICAL_STORE: Initializing client for URL: {base_url}"); + + // The builder and login logic remains the same. + let mut client = Client::builder().base_url(base_url).build().await?; + let auth_method = AuthMethod::new_universal_auth(client_id, client_secret); + client.login(auth_method).await?; + + info!("INFISICAL_STORE: Client authenticated successfully."); + Ok(Self { + client, + project_id, + environment, + }) + } +} + +#[async_trait] +impl SecretStore for InfisicalSecretStore { + async fn get_raw(&self, _environment: &str, key: &str) -> Result, SecretStoreError> { + let environment = &self.environment; + info!("INFISICAL_STORE: Getting key '{key}' from environment '{environment}'"); + + let request = GetSecretRequest::builder(key, &self.project_id, environment).build(); + + match self.client.secrets().get(request).await { + Ok(secret) => Ok(secret.secret_value.into_bytes()), + Err(e) => { + // Correctly match against the actual InfisicalError enum. + match e { + // The specific case for a 404 Not Found error. + InfisicalError::HttpError { status, .. } + if status == http::StatusCode::NOT_FOUND => + { + Err(SecretStoreError::NotFound { + namespace: environment.to_string(), + key: key.to_string(), + }) + } + // For all other errors, wrap them in our generic Store error. + _ => Err(SecretStoreError::Store(Box::new(e))), + } + } + } + } + + async fn set_raw( + &self, + _environment: &str, + key: &str, + val: &[u8], + ) -> Result<(), SecretStoreError> { + info!( + "INFISICAL_STORE: Setting key '{key}' in environment '{}'", + self.environment + ); + let value_str = + String::from_utf8(val.to_vec()).map_err(|e| SecretStoreError::Store(Box::new(e)))?; + + // --- Upsert Logic --- + // First, attempt to update the secret. + let update_req = UpdateSecretRequest::builder(key, &self.project_id, &self.environment) + .secret_value(&value_str) + .build(); + + match self.client.secrets().update(update_req).await { + Ok(_) => { + info!("INFISICAL_STORE: Successfully updated secret '{key}'."); + Ok(()) + } + Err(e) => { + // If the update failed, check if it was because the secret doesn't exist. + match e { + InfisicalError::HttpError { status, .. } + if status == http::StatusCode::NOT_FOUND => + { + // The secret was not found, so we create it instead. + warn!( + "INFISICAL_STORE: Secret '{key}' not found for update, attempting to create it." + ); + let create_req = CreateSecretRequest::builder( + key, + &value_str, + &self.project_id, + &self.environment, + ) + .build(); + + // Handle potential errors during creation. + self.client + .secrets() + .create(create_req) + .await + .map_err(|create_err| SecretStoreError::Store(Box::new(create_err)))?; + + info!("INFISICAL_STORE: Successfully created secret '{key}'."); + Ok(()) + } + // Any other error during update is a genuine failure. + _ => Err(SecretStoreError::Store(Box::new(e))), + } + } + } + } +} diff --git a/harmony_secret/src/store/local_file.rs b/harmony_secret/src/store/local_file.rs new file mode 100644 index 0000000..84334fa --- /dev/null +++ b/harmony_secret/src/store/local_file.rs @@ -0,0 +1,105 @@ +use async_trait::async_trait; +use log::info; +use std::path::{Path, PathBuf}; + +use crate::{SecretStore, SecretStoreError}; + +#[derive(Debug, Default)] +pub struct LocalFileSecretStore; + +impl LocalFileSecretStore { + /// Helper to consistently generate the secret file path. + fn get_file_path(base_dir: &Path, ns: &str, key: &str) -> PathBuf { + base_dir.join(format!("{ns}_{key}.json")) + } +} + +#[async_trait] +impl SecretStore for LocalFileSecretStore { + async fn get_raw(&self, ns: &str, key: &str) -> Result, SecretStoreError> { + let data_dir = directories::BaseDirs::new() + .expect("Could not find a valid home directory") + .data_dir() + .join("harmony") + .join("secrets"); + + let file_path = Self::get_file_path(&data_dir, ns, key); + info!( + "LOCAL_STORE: Getting key '{key}' from namespace '{ns}' at {}", + file_path.display() + ); + + tokio::fs::read(&file_path) + .await + .map_err(|_| SecretStoreError::NotFound { + namespace: ns.to_string(), + key: key.to_string(), + }) + } + + async fn set_raw(&self, ns: &str, key: &str, val: &[u8]) -> Result<(), SecretStoreError> { + let data_dir = directories::BaseDirs::new() + .expect("Could not find a valid home directory") + .data_dir() + .join("harmony") + .join("secrets"); + + let file_path = Self::get_file_path(&data_dir, ns, key); + info!( + "LOCAL_STORE: Setting key '{key}' in namespace '{ns}' at {}", + file_path.display() + ); + + if let Some(parent_dir) = file_path.parent() { + tokio::fs::create_dir_all(parent_dir) + .await + .map_err(|e| SecretStoreError::Store(Box::new(e)))?; + } + + tokio::fs::write(&file_path, val) + .await + .map_err(|e| SecretStoreError::Store(Box::new(e))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn test_set_and_get_raw_successfully() { + let dir = tempdir().unwrap(); + let store = LocalFileSecretStore::default(); + let ns = "test-ns"; + let key = "test-key"; + let value = b"{\"data\":\"test-value\"}"; + + // To test the store directly, we override the base directory logic. + // For this test, we'll manually construct the path within our temp dir. + let file_path = LocalFileSecretStore::get_file_path(dir.path(), ns, key); + + // Manually write to the temp path to simulate the store's behavior + tokio::fs::create_dir_all(file_path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&file_path, value).await.unwrap(); + + // Now, test get_raw by reading from that same temp path (by mocking the path logic) + let retrieved_value = tokio::fs::read(&file_path).await.unwrap(); + assert_eq!(retrieved_value, value); + } + + #[tokio::test] + async fn test_get_raw_not_found() { + let dir = tempdir().unwrap(); + let ns = "test-ns"; + let key = "non-existent-key"; + + // We need to check if reading a non-existent file gives the correct error + let file_path = LocalFileSecretStore::get_file_path(dir.path(), ns, key); + let result = tokio::fs::read(&file_path).await; + + assert!(matches!(result, Err(_))); + } +} diff --git a/harmony_secret/src/store/mod.rs b/harmony_secret/src/store/mod.rs new file mode 100644 index 0000000..9610e55 --- /dev/null +++ b/harmony_secret/src/store/mod.rs @@ -0,0 +1,4 @@ +mod infisical; +mod local_file; +pub use infisical::*; +pub use local_file::*; diff --git a/harmony_secret/test_harmony_secret_infisical.sh b/harmony_secret/test_harmony_secret_infisical.sh new file mode 100644 index 0000000..00adb57 --- /dev/null +++ b/harmony_secret/test_harmony_secret_infisical.sh @@ -0,0 +1,8 @@ +export HARMONY_SECRET_NAMESPACE=harmony_test_secrets +export HARMONY_SECRET_INFISICAL_URL=http://localhost +export HARMONY_SECRET_INFISICAL_PROJECT_ID=eb4723dc-eede-44d7-98cc-c8e0caf29ccb +export HARMONY_SECRET_INFISICAL_ENVIRONMENT=dev +export HARMONY_SECRET_INFISICAL_CLIENT_ID=dd16b07f-0e38-4090-a1d0-922de9f44d91 +export HARMONY_SECRET_INFISICAL_CLIENT_SECRET=bd2ae054e7759b11ca2e908494196337cc800bab138cb1f59e8d9b15ca3f286f + +cargo test diff --git a/harmony_secret_derive/Cargo.toml b/harmony_secret_derive/Cargo.toml new file mode 100644 index 0000000..5d24b72 --- /dev/null +++ b/harmony_secret_derive/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "harmony-secret-derive" +version = "0.1.0" +edition = "2024" + +[lib] +proc-macro = true + +[dependencies] +quote = "1.0" +proc-macro2 = "1.0" +proc-macro-crate = "3.3" +syn = "2.0" diff --git a/harmony_secret_derive/src/lib.rs b/harmony_secret_derive/src/lib.rs new file mode 100644 index 0000000..8aa83df --- /dev/null +++ b/harmony_secret_derive/src/lib.rs @@ -0,0 +1,38 @@ +use proc_macro::TokenStream; +use proc_macro_crate::{FoundCrate, crate_name}; +use quote::quote; +use syn::{DeriveInput, Ident, parse_macro_input}; + +#[proc_macro_derive(Secret)] +pub fn derive_secret(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + let struct_ident = &input.ident; + + // The key for the secret will be the stringified name of the struct itself. + // e.g., `struct OKDClusterSecret` becomes key `"OKDClusterSecret"`. + let key = struct_ident.to_string(); + + // Find the path to the `harmony_secret` crate. + let secret_crate_path = match crate_name("harmony-secret") { + Ok(FoundCrate::Itself) => quote!(crate), + Ok(FoundCrate::Name(name)) => { + let ident = Ident::new(&name, proc_macro2::Span::call_site()); + quote!(::#ident) + } + Err(e) => { + return syn::Error::new(proc_macro2::Span::call_site(), e.to_string()) + .to_compile_error() + .into(); + } + }; + + // The generated code now implements `Secret` for the struct itself. + // The struct must also derive `Serialize` and `Deserialize` for this to be useful. + let expanded = quote! { + impl #secret_crate_path::Secret for #struct_ident { + const KEY: &'static str = #key; + } + }; + + TokenStream::from(expanded) +} diff --git a/opnsense-config-xml/Cargo.toml b/opnsense-config-xml/Cargo.toml index ad61922..ef0d426 100644 --- a/opnsense-config-xml/Cargo.toml +++ b/opnsense-config-xml/Cargo.toml @@ -12,7 +12,7 @@ env_logger = { workspace = true } yaserde = { git = "https://github.com/jggc/yaserde.git" } yaserde_derive = { git = "https://github.com/jggc/yaserde.git" } xml-rs = "0.8" -thiserror = "1.0" +thiserror.workspace = true async-trait = { workspace = true } tokio = { workspace = true } uuid = { workspace = true }