feat(secrets): rewrite secrets.py for SOPS, update cli.py

secrets.py: replace Pulumi ESC (esc CLI) with SOPS decrypt. Reads
.env.prod.sops via `sops --decrypt`, parses dotenv output. Same public
API: get_secret(), list_secrets(), test_connection().

cli.py: update secrets subcommand help text and test command messaging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-26 10:44:25 +01:00
parent 9d0e6843f4
commit 6d716a83ae
2 changed files with 45 additions and 22 deletions

View File

@@ -106,7 +106,7 @@ def pipeline_list():
typer.echo(f"{name:<15} (command: {cmd}, timeout: {config['timeout_seconds']}s)") typer.echo(f"{name:<15} (command: {cmd}, timeout: {config['timeout_seconds']}s)")
secrets_app = typer.Typer(help="Manage secrets via Pulumi ESC") secrets_app = typer.Typer(help="Manage secrets via SOPS + age")
app.add_typer(secrets_app, name="secrets") app.add_typer(secrets_app, name="secrets")
@@ -142,15 +142,15 @@ def secrets_get(
@secrets_app.command("test") @secrets_app.command("test")
def secrets_test(): def secrets_test():
"""Test ESC connection and authentication.""" """Test sops decryption (verifies sops is installed and age key is present)."""
from materia.secrets import test_connection from materia.secrets import test_connection
typer.echo("Testing Pulumi ESC connection...") typer.echo("Testing SOPS decryption...")
if test_connection(): if test_connection():
typer.echo("ESC connection successful") typer.echo("SOPS decryption successful")
else: else:
typer.echo("ESC connection failed", err=True) typer.echo("SOPS decryption failed", err=True)
typer.echo("\nMake sure you've run: esc login") typer.echo("\nMake sure sops is installed and your age key is at ~/.config/sops/age/keys.txt")
raise typer.Exit(1) raise typer.Exit(1)

View File

@@ -1,44 +1,67 @@
"""Secrets management via Pulumi ESC.""" """Secrets management via SOPS + age."""
import json
import subprocess import subprocess
from functools import lru_cache from functools import lru_cache
from pathlib import Path
# Default secrets file path (relative to repo root)
_DEFAULT_SECRETS_PATH = Path(__file__).parent.parent.parent / ".env.prod.sops"
def _parse_dotenv(text: str) -> dict[str, str]:
"""Parse dotenv-format text into a dict, skipping comments and blanks."""
result = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, _, value = line.partition("=")
result[key.strip()] = value.strip()
return result
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def _load_environment() -> dict[str, str]: def _load_environment(secrets_path: str = None) -> dict[str, str]:
"""Load secrets from Pulumi ESC environment.""" """Decrypt and load secrets from a SOPS-encrypted dotenv file."""
path = Path(secrets_path) if secrets_path else _DEFAULT_SECRETS_PATH
assert path.exists(), f"Secrets file not found: {path}"
try: try:
result = subprocess.run( result = subprocess.run(
["esc", "env", "open", "beanflows/prod", "--format", "json"], ["sops", "--input-type", "dotenv", "--output-type", "dotenv", "--decrypt", str(path)],
capture_output=True, capture_output=True,
text=True, text=True,
check=True, check=True,
timeout=30,
) )
data = json.loads(result.stdout) return _parse_dotenv(result.stdout)
return data.get("environmentVariables", {})
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to load ESC environment: {e.stderr}") raise RuntimeError(f"Failed to decrypt secrets: {e.stderr.strip()}")
except FileNotFoundError: except FileNotFoundError:
raise RuntimeError("ESC CLI not found. Install with: curl -fsSL https://get.pulumi.com/esc/install.sh | sh") raise RuntimeError(
"sops not found. Install with: brew install sops "
"or see https://github.com/getsops/sops/releases"
)
def get_secret(key: str) -> str | None: def get_secret(key: str, secrets_path: str = None) -> str | None:
"""Get a secret value by key.""" """Get a secret value by key."""
env = _load_environment() env = _load_environment(secrets_path)
return env.get(key) return env.get(key)
def list_secrets() -> list[str]: def list_secrets(secrets_path: str = None) -> list[str]:
"""List all available secret keys.""" """List all available secret keys."""
env = _load_environment() env = _load_environment(secrets_path)
return list(env.keys()) return list(env.keys())
def test_connection() -> bool: def test_connection(secrets_path: str = None) -> bool:
"""Test ESC connection.""" """Test that sops is available and can decrypt the secrets file."""
try: try:
_load_environment() _load_environment.cache_clear()
_load_environment(secrets_path)
return True return True
except Exception: except Exception:
return False return False