New crates
- platform-nats: async_nats JetStream bridge; pull/push consumers, explicit ACK,
subject prefixing under provisioning.>, 6 stream definitions on startup
- platform-db: SurrealDB pool (embedded RocksDB solo, Surreal<Mem> tests,
WebSocket server multi-user); migrate() with DEFINE TABLE IF NOT EXISTS DDL
Service integrations
- orchestrator: NATS pub on task state transitions, execution_logs → SurrealDB,
webhook handler (HMAC-SHA256), AuditCollector (batch INSERT, 100-event/1s flush)
- control-center: solo_auth_middleware (intentional bypass, --mode solo only),
NATS session events, WebSocket bridge via JetStream subscription (no polling)
- vault-service: NATS lease flow; credentials over HTTPS only (lease_id in NATS);
SurrealDB storage backend with MVCC retry + exponential backoff
- secretumvault: complete SurrealDB backend replacing HashMap; 9 unit + 19 integration tests
- extension-registry: NATS lifecycle events, vault:// credential resolver with TTL cache,
cache invalidation via provisioning.workspace.*.deploy.done
Clippy workspace clean
cargo clippy --workspace -- -D warnings: 0 errors
Patterns fixed: derivable_impls (#[default] on enum variants), excessive_nesting
(let-else, boolean arithmetic in retain, extracted helpers), io_error_other,
redundant_closure, iter_kv_map, manual_range_contains, pathbuf_instead_of_path
215 lines
6.6 KiB
Rust
215 lines
6.6 KiB
Rust
use std::path::Path;
|
||
use std::process::Command;
|
||
|
||
use crate::error::{ConfigError, Result};
|
||
|
||
/// SOPS (Secrets Operations) integration for decrypting encrypted configs
|
||
#[derive(Debug, Clone)]
|
||
pub struct SopsDecryptor {
|
||
sops_executable: String,
|
||
}
|
||
|
||
impl SopsDecryptor {
|
||
/// Create a new SOPS decryptor
|
||
pub fn new() -> Result<Self> {
|
||
// Check if sops is installed
|
||
match Command::new("sops").arg("--version").output() {
|
||
Ok(output) if output.status.success() => {
|
||
let version = String::from_utf8_lossy(&output.stdout);
|
||
tracing::debug!("SOPS available: {}", version.trim());
|
||
Ok(Self {
|
||
sops_executable: "sops".to_string(),
|
||
})
|
||
}
|
||
_ => Err(ConfigError::io_error(
|
||
"SOPS executable not found. Install SOPS: https://github.com/mozilla/sops",
|
||
)),
|
||
}
|
||
}
|
||
|
||
/// Check if SOPS is available
|
||
pub fn is_available() -> bool {
|
||
Command::new("sops")
|
||
.arg("--version")
|
||
.output()
|
||
.map(|output| output.status.success())
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// Decrypt a SOPS-encrypted file and return the plaintext content
|
||
pub fn decrypt_file<P: AsRef<Path>>(&self, path: P) -> Result<String> {
|
||
let path = path.as_ref();
|
||
|
||
if !path.exists() {
|
||
return Err(ConfigError::not_found(format!(
|
||
"SOPS file not found: {:?}",
|
||
path
|
||
)));
|
||
}
|
||
|
||
tracing::debug!("Decrypting SOPS file: {:?}", path);
|
||
|
||
match Command::new(&self.sops_executable)
|
||
.arg("--decrypt")
|
||
.arg(path)
|
||
.output()
|
||
{
|
||
Ok(output) => {
|
||
if !output.status.success() {
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
return Err(ConfigError::validation_failed(format!(
|
||
"SOPS decryption failed: {}",
|
||
stderr
|
||
)));
|
||
}
|
||
|
||
let content = String::from_utf8(output.stdout).map_err(|e| {
|
||
ConfigError::deserialization_failed(format!(
|
||
"Invalid UTF-8 in SOPS output: {}",
|
||
e
|
||
))
|
||
})?;
|
||
|
||
tracing::debug!("Successfully decrypted SOPS file");
|
||
Ok(content)
|
||
}
|
||
Err(e) => Err(ConfigError::io_error(format!(
|
||
"Failed to execute SOPS: {}",
|
||
e
|
||
))),
|
||
}
|
||
}
|
||
|
||
/// Try to find and decrypt a secrets file for a service
|
||
/// Looks for patterns: {service}.secrets.ncl.sops,
|
||
/// {service}.secrets.toml.sops
|
||
pub fn find_and_decrypt_secrets(
|
||
&self,
|
||
service_name: &str,
|
||
search_dir: &Path,
|
||
) -> Option<String> {
|
||
let patterns = vec![
|
||
format!("{}.secrets.ncl.sops", service_name),
|
||
format!("{}.secrets.toml.sops", service_name),
|
||
format!("secrets.{}.ncl.sops", service_name),
|
||
format!("secrets.{}.toml.sops", service_name),
|
||
];
|
||
|
||
for pattern in patterns {
|
||
let secret_file = search_dir.join(&pattern);
|
||
if secret_file.exists() {
|
||
match self.decrypt_file(&secret_file) {
|
||
Ok(content) => {
|
||
tracing::info!("Loaded secrets for {}: {}", service_name, pattern);
|
||
return Some(content);
|
||
}
|
||
Err(e) => {
|
||
tracing::warn!("Failed to decrypt {}: {}", pattern, e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
/// Check if a file appears to be SOPS-encrypted
|
||
pub fn is_sops_encrypted(path: &Path) -> bool {
|
||
path.extension()
|
||
.and_then(|ext| ext.to_str())
|
||
.map(|ext| ext == "sops")
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// Get the original file format from a SOPS filename
|
||
/// Example: "config.ncl.sops" → "ncl"
|
||
pub fn get_original_format(sops_path: &Path) -> Option<String> {
|
||
sops_path
|
||
.file_stem()
|
||
.and_then(|stem| stem.to_str())
|
||
.and_then(|stem_str| stem_str.split('.').next_back().map(|ext| ext.to_string()))
|
||
}
|
||
}
|
||
|
||
impl Default for SopsDecryptor {
|
||
fn default() -> Self {
|
||
Self::new().unwrap_or_else(|_| Self {
|
||
sops_executable: "sops".to_string(),
|
||
})
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use tempfile::TempDir;
|
||
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_sops_availability() {
|
||
// This test checks if SOPS is installed
|
||
let available = SopsDecryptor::is_available();
|
||
if available {
|
||
println!("✓ SOPS is available");
|
||
} else {
|
||
println!("ℹ SOPS not installed (expected in CI)");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_sops_decryptor_new() {
|
||
match SopsDecryptor::new() {
|
||
Ok(decryptor) => {
|
||
assert_eq!(decryptor.sops_executable, "sops");
|
||
println!("✓ SOPS decryptor created");
|
||
}
|
||
Err(e) => {
|
||
println!("ℹ SOPS not available: {}", e);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_is_sops_encrypted() {
|
||
assert!(SopsDecryptor::is_sops_encrypted(Path::new(
|
||
"config.ncl.sops"
|
||
)));
|
||
assert!(SopsDecryptor::is_sops_encrypted(Path::new(
|
||
"secrets.toml.sops"
|
||
)));
|
||
assert!(!SopsDecryptor::is_sops_encrypted(Path::new("config.ncl")));
|
||
assert!(!SopsDecryptor::is_sops_encrypted(Path::new("secrets.toml")));
|
||
}
|
||
|
||
#[test]
|
||
fn test_get_original_format() {
|
||
assert_eq!(
|
||
SopsDecryptor::get_original_format(Path::new("config.ncl.sops")),
|
||
Some("ncl".to_string())
|
||
);
|
||
assert_eq!(
|
||
SopsDecryptor::get_original_format(Path::new("secrets.toml.sops")),
|
||
Some("toml".to_string())
|
||
);
|
||
assert_eq!(
|
||
SopsDecryptor::get_original_format(Path::new("config.yaml.sops")),
|
||
Some("yaml".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_find_and_decrypt_secrets_missing() {
|
||
let temp_dir = TempDir::new().unwrap();
|
||
match SopsDecryptor::new() {
|
||
Ok(decryptor) => {
|
||
let result = decryptor.find_and_decrypt_secrets("vault-service", temp_dir.path());
|
||
assert!(result.is_none(), "Should not find any secrets");
|
||
println!("✓ Missing secrets handled correctly");
|
||
}
|
||
Err(_) => {
|
||
println!("ℹ SOPS not available, skipping decrypt test");
|
||
}
|
||
}
|
||
}
|
||
}
|