1use async_trait::async_trait;
4use cuenv_secrets::{SecretError, SecretResolver, SecretSpec};
5use serde::{Deserialize, Serialize};
6use tokio::process::Command;
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10#[serde(rename_all = "camelCase")]
11pub struct GcpSecretConfig {
12 pub project: String,
14
15 pub secret: String,
17
18 #[serde(default = "default_version")]
20 pub version: String,
21}
22
23fn default_version() -> String {
24 "latest".to_string()
25}
26
27impl GcpSecretConfig {
28 #[must_use]
30 pub fn new(project: impl Into<String>, secret: impl Into<String>) -> Self {
31 Self {
32 project: project.into(),
33 secret: secret.into(),
34 version: "latest".to_string(),
35 }
36 }
37
38 #[must_use]
40 pub fn resource_name(&self) -> String {
41 format!(
42 "projects/{}/secrets/{}/versions/{}",
43 self.project, self.secret, self.version
44 )
45 }
46}
47
48pub struct GcpResolver {
58 use_http: bool,
59}
60
61impl std::fmt::Debug for GcpResolver {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 f.debug_struct("GcpResolver")
64 .field("mode", &if self.can_use_http() { "http" } else { "cli" })
65 .finish()
66 }
67}
68
69impl GcpResolver {
70 pub fn new() -> Result<Self, SecretError> {
78 let use_http = Self::http_credentials_available();
79 Ok(Self { use_http })
80 }
81
82 fn http_credentials_available() -> bool {
84 std::env::var("GOOGLE_APPLICATION_CREDENTIALS").is_ok()
85 }
86
87 fn can_use_http(&self) -> bool {
89 self.use_http
90 }
91
92 async fn resolve_http(
94 &self,
95 name: &str,
96 config: &GcpSecretConfig,
97 ) -> Result<String, SecretError> {
98 tracing::warn!(
101 "GCP HTTP mode not yet fully implemented, falling back to CLI for secret '{}'",
102 name
103 );
104 self.resolve_cli(name, config).await
105 }
106
107 async fn resolve_cli(
109 &self,
110 name: &str,
111 config: &GcpSecretConfig,
112 ) -> Result<String, SecretError> {
113 let output = Command::new("gcloud")
114 .args([
115 "secrets",
116 "versions",
117 "access",
118 &config.version,
119 "--secret",
120 &config.secret,
121 "--project",
122 &config.project,
123 ])
124 .output()
125 .await
126 .map_err(|e| SecretError::ResolutionFailed {
127 name: name.to_string(),
128 message: format!("Failed to execute gcloud CLI: {e}"),
129 })?;
130
131 if !output.status.success() {
132 let stderr = String::from_utf8_lossy(&output.stderr);
133 return Err(SecretError::ResolutionFailed {
134 name: name.to_string(),
135 message: format!("gcloud CLI failed: {stderr}"),
136 });
137 }
138
139 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
140 }
141
142 fn parse_resource_name(resource_name: &str) -> Option<GcpSecretConfig> {
144 let parts: Vec<&str> = resource_name.split('/').collect();
146 if parts.len() >= 6
147 && parts[0] == "projects"
148 && parts[2] == "secrets"
149 && parts[4] == "versions"
150 {
151 Some(GcpSecretConfig {
152 project: parts[1].to_string(),
153 secret: parts[3].to_string(),
154 version: parts[5].to_string(),
155 })
156 } else {
157 None
158 }
159 }
160
161 async fn resolve_with_config(
163 &self,
164 name: &str,
165 config: &GcpSecretConfig,
166 ) -> Result<String, SecretError> {
167 if self.use_http {
169 return self.resolve_http(name, config).await;
170 }
171
172 self.resolve_cli(name, config).await
174 }
175}
176
177#[async_trait]
178impl SecretResolver for GcpResolver {
179 fn provider_name(&self) -> &'static str {
180 "gcp"
181 }
182
183 async fn resolve(&self, name: &str, spec: &SecretSpec) -> Result<String, SecretError> {
184 if let Ok(config) = serde_json::from_str::<GcpSecretConfig>(&spec.source) {
186 return self.resolve_with_config(name, &config).await;
187 }
188
189 if let Some(config) = Self::parse_resource_name(&spec.source) {
191 return self.resolve_with_config(name, &config).await;
192 }
193
194 Err(SecretError::ResolutionFailed {
195 name: name.to_string(),
196 message: format!(
197 "Invalid GCP secret source. Expected JSON config or resource name, got: {}",
198 spec.source
199 ),
200 })
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn test_gcp_config_serialization() {
210 let config = GcpSecretConfig {
211 project: "my-project".to_string(),
212 secret: "my-secret".to_string(),
213 version: "latest".to_string(),
214 };
215
216 let json = serde_json::to_string(&config).unwrap();
217 let parsed: GcpSecretConfig = serde_json::from_str(&json).unwrap();
218 assert_eq!(config, parsed);
219 }
220
221 #[test]
222 fn test_resource_name() {
223 let config = GcpSecretConfig::new("my-project", "my-secret");
224 assert_eq!(
225 config.resource_name(),
226 "projects/my-project/secrets/my-secret/versions/latest"
227 );
228 }
229
230 #[test]
231 fn test_parse_resource_name() {
232 let config =
233 GcpResolver::parse_resource_name("projects/my-project/secrets/my-secret/versions/5")
234 .unwrap();
235 assert_eq!(config.project, "my-project");
236 assert_eq!(config.secret, "my-secret");
237 assert_eq!(config.version, "5");
238 }
239
240 #[test]
241 fn test_parse_invalid_resource_name() {
242 assert!(GcpResolver::parse_resource_name("invalid/path").is_none());
243 }
244
245 #[test]
246 fn test_http_credentials_check() {
247 let _ = GcpResolver::http_credentials_available();
249 }
250}