Compare commits
	
		
			1 Commits
		
	
	
		
			26e8e386b9
			...
			9c5d1bd27f
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 9c5d1bd27f | 
							
								
								
									
										184
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										184
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							| @ -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", | ||||
| @ -1823,7 +1832,6 @@ dependencies = [ | ||||
|  "temp-dir", | ||||
|  "temp-file", | ||||
|  "tempfile", | ||||
|  "thiserror 2.0.14", | ||||
|  "tokio", | ||||
|  "tokio-util", | ||||
|  "url", | ||||
| @ -1831,7 +1839,26 @@ dependencies = [ | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "harmony-secrets-derive" | ||||
| 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", | ||||
| @ -2141,7 +2168,7 @@ dependencies = [ | ||||
|  "httpdate", | ||||
|  "itoa", | ||||
|  "pin-project-lite", | ||||
|  "socket2", | ||||
|  "socket2 0.5.10", | ||||
|  "tokio", | ||||
|  "tower-service", | ||||
|  "tracing", | ||||
| @ -2220,6 +2247,7 @@ dependencies = [ | ||||
|  "tokio", | ||||
|  "tokio-rustls", | ||||
|  "tower-service", | ||||
|  "webpki-roots", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| @ -2282,7 +2310,7 @@ dependencies = [ | ||||
|  "libc", | ||||
|  "percent-encoding", | ||||
|  "pin-project-lite", | ||||
|  "socket2", | ||||
|  "socket2 0.5.10", | ||||
|  "system-configuration 0.6.1", | ||||
|  "tokio", | ||||
|  "tower-service", | ||||
| @ -2499,6 +2527,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" | ||||
| @ -2539,6 +2582,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" | ||||
| @ -2908,6 +2962,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" | ||||
| @ -3210,7 +3270,7 @@ dependencies = [ | ||||
|  "pretty_assertions", | ||||
|  "rand 0.8.5", | ||||
|  "serde", | ||||
|  "thiserror 1.0.69", | ||||
|  "thiserror 2.0.14", | ||||
|  "tokio", | ||||
|  "uuid", | ||||
|  "xml-rs", | ||||
| @ -3622,6 +3682,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" | ||||
| @ -3841,6 +3956,7 @@ dependencies = [ | ||||
|  "base64 0.22.1", | ||||
|  "bytes", | ||||
|  "encoding_rs", | ||||
|  "futures-channel", | ||||
|  "futures-core", | ||||
|  "futures-util", | ||||
|  "h2 0.4.10", | ||||
| @ -3857,6 +3973,8 @@ dependencies = [ | ||||
|  "native-tls", | ||||
|  "percent-encoding", | ||||
|  "pin-project-lite", | ||||
|  "quinn", | ||||
|  "rustls", | ||||
|  "rustls-pki-types", | ||||
|  "serde", | ||||
|  "serde_json", | ||||
| @ -3864,6 +3982,7 @@ dependencies = [ | ||||
|  "sync_wrapper 1.0.2", | ||||
|  "tokio", | ||||
|  "tokio-native-tls", | ||||
|  "tokio-rustls", | ||||
|  "tokio-util", | ||||
|  "tower", | ||||
|  "tower-http", | ||||
| @ -3873,6 +3992,7 @@ dependencies = [ | ||||
|  "wasm-bindgen-futures", | ||||
|  "wasm-streams", | ||||
|  "web-sys", | ||||
|  "webpki-roots", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| @ -4062,6 +4182,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" | ||||
| @ -4161,6 +4287,7 @@ version = "1.12.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" | ||||
| dependencies = [ | ||||
|  "web-time", | ||||
|  "zeroize", | ||||
| ] | ||||
| 
 | ||||
| @ -4646,6 +4773,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" | ||||
| @ -5008,20 +5145,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]] | ||||
| @ -5572,6 +5727,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" | ||||
|  | ||||
| @ -12,7 +12,8 @@ members = [ | ||||
|   "harmony_cli", | ||||
|   "k3d", | ||||
|   "harmony_composer", | ||||
|   "harmony_secrets_derive", | ||||
|   "harmony_secret_derive", | ||||
|   "harmony_secret", | ||||
| ] | ||||
| 
 | ||||
| [workspace.package] | ||||
| @ -54,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" | ||||
|  | ||||
| @ -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,7 +67,7 @@ bollard.workspace = true | ||||
| tar.workspace = true | ||||
| base64.workspace = true | ||||
| once_cell = "1.21.3" | ||||
| thiserror = "2.0.14" | ||||
| harmony-secret-derive = { version = "0.1.0", path = "../harmony_secret_derive" } | ||||
| 
 | ||||
| [dev-dependencies] | ||||
| pretty_assertions.workspace = true | ||||
|  | ||||
| @ -9,4 +9,3 @@ pub mod inventory; | ||||
| pub mod maestro; | ||||
| pub mod score; | ||||
| pub mod topology; | ||||
| pub mod secrets; | ||||
|  | ||||
| @ -1,265 +0,0 @@ | ||||
| //! # Harmony Secrets Module
 | ||||
| //!
 | ||||
| //! This module provides core abstractions for type-safe secret management within the Harmony framework.
 | ||||
| //!
 | ||||
| //! ## Design Philosophy
 | ||||
| //!
 | ||||
| //! The design is centered around three key components:
 | ||||
| //!
 | ||||
| //! 1.  **The `Secret` Trait:** This is the heart of the module. Instead of using strings to identify
 | ||||
| //!     secrets, we use dedicated, zero-sized types (ZSTs). Each ZST represents a single secret
 | ||||
| //!     and implements the `Secret` trait to provide metadata (like its namespace and key) and
 | ||||
| //!     its associated `Value` type. This enables full compile-time verification.
 | ||||
| //!
 | ||||
| //! 2.  **The `Secrets` Struct:** This is the primary user-facing API. It provides the `get` and `set`
 | ||||
| //!     methods that are generic over any type implementing `Secret`. It's the high-level,
 | ||||
| //!     easy-to-use entry point for all secret operations.
 | ||||
| //!
 | ||||
| //! 3.  **The `SecretStore` Trait:** This is the low-level backend interface. It defines the contract
 | ||||
| //!     for how the `Secrets` struct will interact with an actual storage system (like Infisical,
 | ||||
| //!     a local file, or a database). This decouples the high-level API from the implementation details.
 | ||||
| //!
 | ||||
| //! ## Example Usage
 | ||||
| //!
 | ||||
| //! ```
 | ||||
| //! // In an external crate (e.g., harmony-okd):
 | ||||
| //! use harmony_secrets::{Secret, StoreError};
 | ||||
| //!
 | ||||
| //! // 1. Define a zero-sized struct for each secret.
 | ||||
| //! pub struct KubeadminPassword;
 | ||||
| //!
 | ||||
| //! // 2. Implement the `Secret` trait to provide metadata.
 | ||||
| //! impl Secret for KubeadminPassword {
 | ||||
| //!     // The associated type defines what you get back.
 | ||||
| //!     type Value = String;
 | ||||
| //!
 | ||||
| //!     const NAMESPACE: &'static str = "okd-installation";
 | ||||
| //!     const KEY: &'static str = "kubeadmin-password";
 | ||||
| //! }
 | ||||
| //!
 | ||||
| //! // 3. Use it with the `Secrets` struct.
 | ||||
| //! async fn example(secrets: &harmony_secrets::Secrets) -> Result<(), StoreError> {
 | ||||
| //!     // The API is type-safe. The compiler knows what `Value` to expect.
 | ||||
| //!     secrets.set::<KubeadminPassword>("password123".to_string()).await?;
 | ||||
| //!     let password = secrets.get::<KubeadminPassword>().await?;
 | ||||
| //!     assert_eq!(password, "password123");
 | ||||
| //!     Ok(())
 | ||||
| //! }
 | ||||
| //! ```
 | ||||
| 
 | ||||
| use async_trait::async_trait; | ||||
| use serde::{de::DeserializeOwned, Serialize}; | ||||
| use std::sync::Arc; | ||||
| use thiserror::Error; | ||||
| 
 | ||||
| /// Defines the set of errors that can occur during secret operations.
 | ||||
| /// Using `thiserror` provides a great developer experience for error handling.
 | ||||
| #[derive(Debug, Error)] | ||||
| pub enum StoreError { | ||||
|     #[error("Secret not found in store: namespace='{namespace}', key='{key}'")] | ||||
|     NotFound { namespace: String, key: String }, | ||||
| 
 | ||||
|     #[error("Permission denied for secret: namespace='{namespace}', key='{key}'")] | ||||
|     PermissionDenied { namespace: String, key: String }, | ||||
| 
 | ||||
|     #[error("Failed to deserialize secret value: {0}")] | ||||
|     Deserialization(String), | ||||
| 
 | ||||
|     #[error("Failed to serialize secret value: {0}")] | ||||
|     Serialization(String), | ||||
| 
 | ||||
|     #[error("A backend-specific error occurred: {0}")] | ||||
|     Backend(String), | ||||
| } | ||||
| 
 | ||||
| /// A trait that marks a type as representing a single, retrievable secret.
 | ||||
| ///
 | ||||
| /// This trait should be implemented on a unique, zero-sized struct for each secret
 | ||||
| /// your module needs to manage. This pattern ensures that all secret access is
 | ||||
| /// validated at compile time.
 | ||||
| pub trait Secret: 'static + Send + Sync { | ||||
|     /// The data type of the secret's value. This ensures that `get` and `set`
 | ||||
|     /// operations are fully type-safe. The value must be serializable.
 | ||||
|     type Value: Serialize + DeserializeOwned + Send; | ||||
| 
 | ||||
|     /// A logical grouping for secrets, similar to a Kubernetes namespace or a
 | ||||
|     /// directory path. This will be used by the `SecretStore` to organize data.
 | ||||
|     const NAMESPACE: &'static str; | ||||
| 
 | ||||
|     /// The unique key for the secret within its namespace.
 | ||||
|     const KEY: &'static str; | ||||
| } | ||||
| 
 | ||||
| /// The low-level storage trait that concrete secret backends must implement.
 | ||||
| ///
 | ||||
| /// This trait operates on raw bytes (`Vec<u8>`), keeping it decoupled from any
 | ||||
| /// specific serialization format. The `Secrets` struct will handle the
 | ||||
| //  serialization/deserialization boundary.
 | ||||
| #[async_trait] | ||||
| pub trait SecretStore: Send + Sync { | ||||
|     /// Retrieves the raw byte value of a secret from the backend.
 | ||||
|     async fn get(&self, namespace: &str, key: &str) -> Result<Vec<u8>, StoreError>; | ||||
| 
 | ||||
|     /// Saves the raw byte value of a secret to the backend.
 | ||||
|     async fn set(&self, namespace: &str, key: &str, value: Vec<u8>) -> Result<(), StoreError>; | ||||
| } | ||||
| 
 | ||||
| /// The primary, user-facing struct for interacting with secrets.
 | ||||
| ///
 | ||||
| /// It provides a high-level, type-safe API that is decoupled from the
 | ||||
| /// underlying storage mechanism.
 | ||||
| #[derive(Clone)] | ||||
| pub struct Secrets { | ||||
|     /// A shared, thread-safe reference to the underlying secret store.
 | ||||
|     store: Arc<dyn SecretStore>, | ||||
| } | ||||
| 
 | ||||
| impl Secrets { | ||||
|     /// Creates a new `Secrets` instance with the given store implementation.
 | ||||
|     pub fn new(store: Arc<dyn SecretStore>) -> Self { | ||||
|         Self { store } | ||||
|     } | ||||
| 
 | ||||
|     /// Retrieves a secret from the store in a fully type-safe manner.
 | ||||
|     ///
 | ||||
|     /// The type of the secret to retrieve is specified using a generic parameter `S`,
 | ||||
|     /// which must implement the `Secret` trait. The method returns the `S::Value`
 | ||||
|     /// associated type, ensuring you always get the data type you expect.
 | ||||
|     ///
 | ||||
|     /// # Example
 | ||||
|     /// `let admin_pass = secrets.get::<my_secrets::AdminPassword>().await?;`
 | ||||
|     pub async fn get<S: Secret>(&self) -> Result<S::Value, StoreError> { | ||||
|         let bytes = self | ||||
|             .store | ||||
|             .get(S::NAMESPACE, S::KEY) | ||||
|             .await | ||||
|             .map_err(|e| match e { | ||||
|                 // Preserve the NotFound error for better diagnostics.
 | ||||
|                 StoreError::NotFound { .. } => e, | ||||
|                 _ => StoreError::Backend(e.to_string()), | ||||
|             })?; | ||||
| 
 | ||||
|         // The public API uses JSON for serialization. It's robust and human-readable.
 | ||||
|         serde_json::from_slice(&bytes) | ||||
|             .map_err(|e| StoreError::Deserialization(e.to_string())) | ||||
|     } | ||||
| 
 | ||||
|     /// Saves a secret to the store in a fully type-safe manner.
 | ||||
|     ///
 | ||||
|     /// The method is generic over the secret type `S`, and the `value` parameter
 | ||||
|     /// must match the `S::Value` associated type, preventing type mismatch errors
 | ||||
|     /// at compile time.
 | ||||
|     ///
 | ||||
|     /// # Example
 | ||||
|     /// `secrets.set::<my_secrets::AdminPassword>("new-password".to_string()).await?;`
 | ||||
|     pub async fn set<S: Secret>(&self, value: S::Value) -> Result<(), StoreError> { | ||||
|         let bytes = serde_json::to_vec(&value) | ||||
|             .map_err(|e| StoreError::Serialization(e.to_string()))?; | ||||
| 
 | ||||
|         self.store | ||||
|             .set(S::NAMESPACE, S::KEY, bytes) | ||||
|             .await | ||||
|             .map_err(|e| StoreError::Backend(e.to_string())) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use super::*; | ||||
|     use std::collections::HashMap; | ||||
|     use std::sync::Mutex; | ||||
| 
 | ||||
|     // Define a dummy secret for testing purposes.
 | ||||
|     struct TestApiKey; | ||||
|     impl Secret for TestApiKey { | ||||
|         type Value = String; | ||||
|         const NAMESPACE: &'static str = "global-tests"; | ||||
|         const KEY: &'static str = "api-key"; | ||||
|     } | ||||
| 
 | ||||
|     struct ComplexSecret; | ||||
|     #[derive(Serialize, serde::Deserialize, PartialEq, Debug, Clone)] | ||||
|     struct ComplexValue { | ||||
|         user: String, | ||||
|         permissions: Vec<String>, | ||||
|     } | ||||
|     impl Secret for ComplexSecret { | ||||
|         type Value = ComplexValue; | ||||
|         const NAMESPACE: &'static str = "complex-tests"; | ||||
|         const KEY: &'static str = "user-data"; | ||||
|     } | ||||
| 
 | ||||
|     // A mock implementation of the `SecretStore` that uses an in-memory HashMap.
 | ||||
|     #[derive(Default)] | ||||
|     struct MockStore { | ||||
|         data: Mutex<HashMap<String, Vec<u8>>>, | ||||
|     } | ||||
| 
 | ||||
|     #[async_trait] | ||||
|     impl SecretStore for MockStore { | ||||
|         async fn get(&self, namespace: &str, key: &str) -> Result<Vec<u8>, StoreError> { | ||||
|             let path = format!("{}/{}", namespace, key); | ||||
|             let data = self.data.lock().unwrap(); | ||||
|             data.get(&path) | ||||
|                 .cloned() | ||||
|                 .ok_or_else(|| StoreError::NotFound { | ||||
|                     namespace: namespace.to_string(), | ||||
|                     key: key.to_string(), | ||||
|                 }) | ||||
|         } | ||||
| 
 | ||||
|         async fn set(&self, namespace: &str, key: &str, value: Vec<u8>) -> Result<(), StoreError> { | ||||
|             let path = format!("{}/{}", namespace, key); | ||||
|             let mut data = self.data.lock().unwrap(); | ||||
|             data.insert(path, value); | ||||
|             Ok(()) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     #[tokio::test] | ||||
|     async fn test_set_and_get_simple_secret() { | ||||
|         let store = Arc::new(MockStore::default()); | ||||
|         let secrets = Secrets::new(store); | ||||
| 
 | ||||
|         let api_key_value = "secret-key-12345".to_string(); | ||||
|         secrets | ||||
|             .set::<TestApiKey>(api_key_value.clone()) | ||||
|             .await | ||||
|             .unwrap(); | ||||
| 
 | ||||
|         let retrieved_key = secrets.get::<TestApiKey>().await.unwrap(); | ||||
|         assert_eq!(retrieved_key, api_key_value); | ||||
|     } | ||||
| 
 | ||||
|     #[tokio::test] | ||||
|     async fn test_set_and_get_complex_secret() { | ||||
|         let store = Arc::new(MockStore::default()); | ||||
|         let secrets = Secrets::new(store); | ||||
| 
 | ||||
|         let complex_value = ComplexValue { | ||||
|             user: "test-user".to_string(), | ||||
|             permissions: vec!["read".to_string(), "write".to_string()], | ||||
|         }; | ||||
|         secrets | ||||
|             .set::<ComplexSecret>(complex_value.clone()) | ||||
|             .await | ||||
|             .unwrap(); | ||||
| 
 | ||||
|         let retrieved_value = secrets.get::<ComplexSecret>().await.unwrap(); | ||||
|         assert_eq!(retrieved_value, complex_value); | ||||
|     } | ||||
| 
 | ||||
|     #[tokio::test] | ||||
|     async fn test_get_nonexistent_secret() { | ||||
|         let store = Arc::new(MockStore::default()); | ||||
|         let secrets = Secrets::new(store); | ||||
| 
 | ||||
|         let result = secrets.get::<TestApiKey>().await; | ||||
|         assert!(matches!(result, Err(StoreError::NotFound { .. }))); | ||||
| 
 | ||||
|         if let Err(StoreError::NotFound { namespace, key }) = result { | ||||
|             assert_eq!(namespace, TestApiKey::NAMESPACE); | ||||
|             assert_eq!(key, TestApiKey::KEY); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										23
									
								
								harmony_secret/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								harmony_secret/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							| @ -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 | ||||
							
								
								
									
										18
									
								
								harmony_secret/src/config.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								harmony_secret/src/config.rs
									
									
									
									
									
										Normal file
									
								
							| @ -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<String> = | ||||
|         std::env::var("HARMONY_SECRET_STORE").ok(); | ||||
|     pub static ref INFISICAL_URL: Option<String> = | ||||
|         std::env::var("HARMONY_SECRET_INFISICAL_URL").ok(); | ||||
|     pub static ref INFISICAL_PROJECT_ID: Option<String> = | ||||
|         std::env::var("HARMONY_SECRET_INFISICAL_PROJECT_ID").ok(); | ||||
|     pub static ref INFISICAL_ENVIRONMENT: Option<String> = | ||||
|         std::env::var("HARMONY_SECRET_INFISICAL_ENVIRONMENT").ok(); | ||||
|     pub static ref INFISICAL_CLIENT_ID: Option<String> = | ||||
|         std::env::var("HARMONY_SECRET_INFISICAL_CLIENT_ID").ok(); | ||||
|     pub static ref INFISICAL_CLIENT_SECRET: Option<String> = | ||||
|         std::env::var("HARMONY_SECRET_INFISICAL_CLIENT_SECRET").ok(); | ||||
| } | ||||
							
								
								
									
										166
									
								
								harmony_secret/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										166
									
								
								harmony_secret/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							| @ -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<dyn std::error::Error + Send + Sync>), | ||||
| } | ||||
| 
 | ||||
| // The trait is now async!
 | ||||
| #[async_trait] | ||||
| pub trait SecretStore: fmt::Debug + Send + Sync { | ||||
|     async fn get_raw(&self, namespace: &str, key: &str) -> Result<Vec<u8>, 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<SecretManager> = 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<dyn SecretStore> = 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<dyn SecretStore>, | ||||
| } | ||||
| 
 | ||||
| impl SecretManager { | ||||
|     fn new(namespace: String, store: Box<dyn SecretStore>) -> Self { | ||||
|         Self { namespace, store } | ||||
|     } | ||||
| 
 | ||||
|     /// Retrieves and deserializes a secret.
 | ||||
|     pub async fn get<T: Secret>() -> Result<T, SecretStoreError> { | ||||
|         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<T: Secret>(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<String>, | ||||
|     } | ||||
| 
 | ||||
|     #[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 ç <20> <20> <20> <20> <20> <20> <20> <20> <20> <20> <20> <20> <20>  👩👩👧👦  /span>  👩👧👦 and why not emojis ", | ||||
|                     ), | ||||
|                 ], | ||||
|             }, | ||||
|         }; | ||||
| 
 | ||||
|         SecretManager::set(&secret).await.unwrap(); | ||||
|         let value = SecretManager::get::<TestSecret>().await.unwrap(); | ||||
| 
 | ||||
|         assert_eq!(value, secret); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										129
									
								
								harmony_secret/src/store/infisical.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								harmony_secret/src/store/infisical.rs
									
									
									
									
									
										Normal file
									
								
							| @ -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<Self, InfisicalError> { | ||||
|         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<Vec<u8>, 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))), | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										105
									
								
								harmony_secret/src/store/local_file.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								harmony_secret/src/store/local_file.rs
									
									
									
									
									
										Normal file
									
								
							| @ -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<Vec<u8>, 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(_))); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										4
									
								
								harmony_secret/src/store/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								harmony_secret/src/store/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,4 @@ | ||||
| mod infisical; | ||||
| mod local_file; | ||||
| pub use infisical::*; | ||||
| pub use local_file::*; | ||||
							
								
								
									
										8
									
								
								harmony_secret/test_harmony_secret_infisical.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								harmony_secret/test_harmony_secret_infisical.sh
									
									
									
									
									
										Normal file
									
								
							| @ -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 | ||||
| @ -1,5 +1,5 @@ | ||||
| [package] | ||||
| name = "harmony-secrets-derive" | ||||
| name = "harmony-secret-derive" | ||||
| version = "0.1.0" | ||||
| edition = "2024" | ||||
| 
 | ||||
							
								
								
									
										38
									
								
								harmony_secret_derive/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								harmony_secret_derive/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							| @ -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) | ||||
| } | ||||
| @ -1,152 +0,0 @@ | ||||
| use syn::DeriveInput; | ||||
| use syn::parse_macro_input; | ||||
| use proc_macro::TokenStream; | ||||
| use proc_macro_crate::{FoundCrate, crate_name}; | ||||
| use quote::quote; | ||||
| use syn::{ | ||||
|     Ident, LitStr, Meta, Token, Type, | ||||
|     parse::{Parse, ParseStream}, | ||||
|     punctuated::Punctuated, | ||||
| }; | ||||
| 
 | ||||
| /// A helper struct to parse the contents of the `#[secret(...)]` attribute.
 | ||||
| /// This makes parsing robust and allows for better error handling.
 | ||||
| struct SecretAttributeArgs { | ||||
|     namespace: LitStr, | ||||
|     key: LitStr, | ||||
|     value_type: Type, | ||||
| } | ||||
| 
 | ||||
| impl Parse for SecretAttributeArgs { | ||||
|     fn parse(input: ParseStream) -> syn::Result<Self> { | ||||
|         // The attributes are parsed as a comma-separated list of `key = value` pairs.
 | ||||
|         let parsed_args = Punctuated::<Meta, Token![,]>::parse_terminated(input)?; | ||||
| 
 | ||||
|         let mut namespace = None; | ||||
|         let mut key = None; | ||||
|         let mut value_type = None; | ||||
| 
 | ||||
|         for arg in parsed_args { | ||||
|             if let Meta::NameValue(nv) = arg { | ||||
|                 let ident_str = nv.path.get_ident().map(Ident::to_string); | ||||
|                 match ident_str.as_deref() { | ||||
|                     Some("namespace") => { | ||||
|                         if let syn::Expr::Lit(expr_lit) = nv.value { | ||||
|                             if let syn::Lit::Str(lit) = expr_lit.lit { | ||||
|                                 namespace = Some(lit); | ||||
|                                 continue; | ||||
|                             } | ||||
|                         } | ||||
|                         return Err(syn::Error::new_spanned( | ||||
|                             nv.value, | ||||
|                             "Expected a string literal for `namespace`", | ||||
|                         )); | ||||
|                     } | ||||
|                     Some("key") => { | ||||
|                         if let syn::Expr::Lit(expr_lit) = nv.value { | ||||
|                             if let syn::Lit::Str(lit) = expr_lit.lit { | ||||
|                                 key = Some(lit); | ||||
|                                 continue; | ||||
|                             } | ||||
|                         } | ||||
|                         return Err(syn::Error::new_spanned( | ||||
|                             nv.value, | ||||
|                             "Expected a string literal for `key`", | ||||
|                         )); | ||||
|                     } | ||||
|                     Some("value_type") => { | ||||
|                         if let syn::Expr::Lit(expr_lit) = nv.value { | ||||
|                             // This is the key improvement: parse the string literal's content as a Type.
 | ||||
|                             if let syn::Lit::Str(lit) = expr_lit.lit { | ||||
|                                 value_type = Some(lit.parse::<Type>()?); | ||||
|                                 continue; | ||||
|                             } | ||||
|                         } | ||||
|                         // This allows for the improved syntax: `value_type = String`
 | ||||
|                         if let syn::Expr::Path(expr_path) = nv.value { | ||||
|                             value_type = Some(Type::Path(expr_path.into())); | ||||
|                             continue; | ||||
|                         } | ||||
|                         return Err(syn::Error::new_spanned( | ||||
|                             nv.value, | ||||
|                             "Expected a type path (e.g., `String` or `Vec<u8>`) for `value_type`", | ||||
|                         )); | ||||
|                     } | ||||
|                     _ => {} | ||||
|                 } | ||||
|             } | ||||
|             return Err(syn::Error::new_spanned( | ||||
|                 arg, | ||||
|                 "Unsupported attribute key. Must be `namespace`, `key`, or `value_type`.", | ||||
|             )); | ||||
|         } | ||||
| 
 | ||||
|         Ok(SecretAttributeArgs { | ||||
|             namespace: namespace.ok_or_else(|| { | ||||
|                 syn::Error::new(input.span(), "Missing required attribute `namespace`") | ||||
|             })?, | ||||
|             key: key | ||||
|                 .ok_or_else(|| syn::Error::new(input.span(), "Missing required attribute `key`"))?, | ||||
|             value_type: value_type.ok_or_else(|| { | ||||
|                 syn::Error::new(input.span(), "Missing required attribute `value_type`") | ||||
|             })?, | ||||
|         }) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[proc_macro_derive(Secret, attributes(secret))] | ||||
| pub fn derive_secret(input: TokenStream) -> TokenStream { | ||||
|     let input = parse_macro_input!(input as DeriveInput); | ||||
| 
 | ||||
|     // Ensure this is a unit struct (e.g., `struct MySecret;`)
 | ||||
|     if !matches!(&input.data, syn::Data::Struct(s) if s.fields.is_empty()) { | ||||
|         return syn::Error::new_spanned( | ||||
|             &input.ident, | ||||
|             "#[derive(Secret)] can only be used on unit structs.", | ||||
|         ) | ||||
|         .to_compile_error() | ||||
|         .into(); | ||||
|     } | ||||
| 
 | ||||
|     // Find the `#[secret(...)]` attribute.
 | ||||
|     let secret_attr = input | ||||
|         .attrs | ||||
|         .iter() | ||||
|         .find(|attr| attr.path().is_ident("secret")) | ||||
|         .ok_or_else(|| syn::Error::new_spanned(&input.ident, "Missing `#[secret(...)]` attribute.")) | ||||
|         .and_then(|attr| attr.parse_args::<SecretAttributeArgs>()); | ||||
| 
 | ||||
|     let args = match secret_attr { | ||||
|         Ok(args) => args, | ||||
|         Err(e) => return e.to_compile_error().into(), | ||||
|     }; | ||||
| 
 | ||||
|     // Find the path to the `harmony_secrets` crate to make the macro work anywhere.
 | ||||
|     let secret_crate_path = match crate_name("harmony-secrets") { | ||||
|         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(); | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     let struct_ident = &input.ident; | ||||
|     let namespace = args.namespace; | ||||
|     let key = args.key; | ||||
|     let value_type = args.value_type; | ||||
| 
 | ||||
|     let expanded = quote! { | ||||
|         impl #secret_crate_path::Secret for #struct_ident { | ||||
|             type Value = #value_type; | ||||
|             const NAMESPACE: &'static str = #namespace; | ||||
|             const KEY: &'static str = #key; | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     TokenStream::from(expanded) | ||||
| } | ||||
| @ -1,100 +0,0 @@ | ||||
| use proc_macro::TokenStream; | ||||
| use syn::{parse_macro_input, DeriveInput, Attribute, Meta}; | ||||
| use quote::quote; | ||||
| use proc_macro_crate::crate_name; | ||||
| 
 | ||||
| #[proc_macro_derive(Secret, attributes(secret))] | ||||
| pub fn derive_secret(input: TokenStream) -> TokenStream { | ||||
|     let input = parse_macro_input!(input as DeriveInput); | ||||
|      | ||||
|     // Verify this is a unit struct | ||||
|     if !matches!(&input.data, syn::Data::Struct(data) if data.fields.is_empty()) { | ||||
|         return syn::Error::new_spanned( | ||||
|             input.ident, | ||||
|             "#[derive(Secret)] only supports unit structs (e.g., `struct MySecret;`)", | ||||
|         ) | ||||
|         .to_compile_error() | ||||
|         .into(); | ||||
|     } | ||||
| 
 | ||||
|     // Parse the #[secret(...)] attribute | ||||
|     let (namespace, key, value_type) = match parse_secret_attributes(&input.attrs) { | ||||
|         Ok(attrs) => attrs, | ||||
|         Err(e) => return e.into_compile_error().into(), | ||||
|     }; | ||||
| 
 | ||||
|     // Get the path to the harmony_secrets crate | ||||
|     let secret_crate_path = match crate_name("harmony-secrets") { | ||||
|         Ok(proc_macro_crate::FoundCrate::Itself) => quote!(crate), | ||||
|         Ok(proc_macro_crate::FoundCrate::Name(name)) => { | ||||
|             let ident = quote::format_ident!("{}", name); | ||||
|             quote!(::#ident) | ||||
|         } | ||||
|         Err(_) => { | ||||
|             return syn::Error::new_spanned( | ||||
|                 &input.ident, | ||||
|                 "harmony-secrets crate not found in dependencies", | ||||
|             ) | ||||
|             .to_compile_error() | ||||
|             .into(); | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     let struct_ident = input.ident; | ||||
| 
 | ||||
|     TokenStream::from(quote! { | ||||
|         impl #secret_crate_path::Secret for #struct_ident { | ||||
|             type Value = #value_type; | ||||
|             const NAMESPACE: &'static str = #namespace; | ||||
|             const KEY: &'static str = #key; | ||||
|         } | ||||
|     }) | ||||
| } | ||||
| 
 | ||||
| fn parse_secret_attributes(attrs: &[Attribute]) -> syn::Result<(String, String, syn::Type)> { | ||||
|     let secret_attr = attrs | ||||
|         .iter() | ||||
|         .find(|attr| attr.path().is_ident("secret")) | ||||
|         .ok_or_else(|| { | ||||
|             syn::Error::new_spanned( | ||||
|                 attrs.first().unwrap_or_else(|| &attrs[0]), | ||||
|                 "missing #[secret(...)] attribute", | ||||
|             ) | ||||
|         })?; | ||||
| 
 | ||||
|     let mut namespace = None; | ||||
|     let mut key = None; | ||||
|     let mut value_type = None; | ||||
| 
 | ||||
|     if let Meta::List(meta_list) = &secret_attr.parse_meta()? { | ||||
|         for nested in &meta_list.nested { | ||||
|             if let syn::NestedMeta::Meta(Meta::NameValue(nv)) = nested { | ||||
|                 if nv.path.is_ident("namespace") { | ||||
|                     if let syn::Lit::Str(lit) = &nv.lit { | ||||
|                         namespace = Some(lit.value()); | ||||
|                     } | ||||
|                 } else if nv.path.is_ident("key") { | ||||
|                     if let syn::Lit::Str(lit) = &nv.lit { | ||||
|                         key = Some(lit.value()); | ||||
|                     } | ||||
|                 } else if nv.path.is_ident("value_type") { | ||||
|                     if let syn::Lit::Str(lit) = &nv.lit { | ||||
|                         value_type = Some(syn::parse_str::<syn::Type>(&lit.value())?); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     Ok(( | ||||
|         namespace.ok_or_else(|| { | ||||
|             syn::Error::new_spanned(secret_attr, "missing `namespace` in #[secret(...)]") | ||||
|         })?, | ||||
|         key.ok_or_else(|| { | ||||
|             syn::Error::new_spanned(secret_attr, "missing `key` in #[secret(...)]") | ||||
|         })?, | ||||
|         value_type.ok_or_else(|| { | ||||
|             syn::Error::new_spanned(secret_attr, "missing `value_type` in #[secret(...)]") | ||||
|         })?, | ||||
|     )) | ||||
| } | ||||
| @ -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 } | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user