Jesús Pérez 93b0e5225c
feat(platform): control plane — NATS JetStream + SurrealDB + SOLID enforcement
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
2026-02-17 23:58:14 +00:00

215 lines
6.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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");
}
}
}
}