use async_trait::async_trait;
use cuenv_secrets::{SecretError, SecretResolver, SecretSpec};
use serde::{Deserialize, Serialize};
use tokio::process::Command;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct GcpSecretConfig {
pub project: String,
pub secret: String,
#[serde(default = "default_version")]
pub version: String,
}
fn default_version() -> String {
"latest".to_string()
}
impl GcpSecretConfig {
#[must_use]
pub fn new(project: impl Into<String>, secret: impl Into<String>) -> Self {
Self {
project: project.into(),
secret: secret.into(),
version: "latest".to_string(),
}
}
#[must_use]
pub fn resource_name(&self) -> String {
format!(
"projects/{}/secrets/{}/versions/{}",
self.project, self.secret, self.version
)
}
}
pub struct GcpResolver {
use_http: bool,
}
impl std::fmt::Debug for GcpResolver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GcpResolver")
.field("mode", &if self.can_use_http() { "http" } else { "cli" })
.finish()
}
}
impl GcpResolver {
pub fn new() -> Result<Self, SecretError> {
let use_http = Self::http_credentials_available();
Ok(Self { use_http })
}
fn http_credentials_available() -> bool {
std::env::var("GOOGLE_APPLICATION_CREDENTIALS").is_ok()
}
fn can_use_http(&self) -> bool {
self.use_http
}
async fn resolve_http(
&self,
name: &str,
config: &GcpSecretConfig,
) -> Result<String, SecretError> {
tracing::warn!(
"GCP HTTP mode not yet fully implemented, falling back to CLI for secret '{}'",
name
);
self.resolve_cli(name, config).await
}
async fn resolve_cli(
&self,
name: &str,
config: &GcpSecretConfig,
) -> Result<String, SecretError> {
let output = Command::new("gcloud")
.args([
"secrets",
"versions",
"access",
&config.version,
"--secret",
&config.secret,
"--project",
&config.project,
])
.output()
.await
.map_err(|e| SecretError::ResolutionFailed {
name: name.to_string(),
message: format!("Failed to execute gcloud CLI: {e}"),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SecretError::ResolutionFailed {
name: name.to_string(),
message: format!("gcloud CLI failed: {stderr}"),
});
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn parse_resource_name(resource_name: &str) -> Option<GcpSecretConfig> {
let parts: Vec<&str> = resource_name.split('/').collect();
if parts.len() >= 6
&& parts[0] == "projects"
&& parts[2] == "secrets"
&& parts[4] == "versions"
{
Some(GcpSecretConfig {
project: parts[1].to_string(),
secret: parts[3].to_string(),
version: parts[5].to_string(),
})
} else {
None
}
}
async fn resolve_with_config(
&self,
name: &str,
config: &GcpSecretConfig,
) -> Result<String, SecretError> {
if self.use_http {
return self.resolve_http(name, config).await;
}
self.resolve_cli(name, config).await
}
}
#[async_trait]
impl SecretResolver for GcpResolver {
fn provider_name(&self) -> &'static str {
"gcp"
}
async fn resolve(&self, name: &str, spec: &SecretSpec) -> Result<String, SecretError> {
if let Ok(config) = serde_json::from_str::<GcpSecretConfig>(&spec.source) {
return self.resolve_with_config(name, &config).await;
}
if let Some(config) = Self::parse_resource_name(&spec.source) {
return self.resolve_with_config(name, &config).await;
}
Err(SecretError::ResolutionFailed {
name: name.to_string(),
message: format!(
"Invalid GCP secret source. Expected JSON config or resource name, got: {}",
spec.source
),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_gcp_config_serialization() {
let config = GcpSecretConfig {
project: "my-project".to_string(),
secret: "my-secret".to_string(),
version: "latest".to_string(),
};
let json = serde_json::to_string(&config).unwrap();
let parsed: GcpSecretConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config, parsed);
}
#[test]
fn test_resource_name() {
let config = GcpSecretConfig::new("my-project", "my-secret");
assert_eq!(
config.resource_name(),
"projects/my-project/secrets/my-secret/versions/latest"
);
}
#[test]
fn test_parse_resource_name() {
let config =
GcpResolver::parse_resource_name("projects/my-project/secrets/my-secret/versions/5")
.unwrap();
assert_eq!(config.project, "my-project");
assert_eq!(config.secret, "my-secret");
assert_eq!(config.version, "5");
}
#[test]
fn test_parse_invalid_resource_name() {
assert!(GcpResolver::parse_resource_name("invalid/path").is_none());
}
#[test]
fn test_http_credentials_check() {
let _ = GcpResolver::http_credentials_available();
}
}