feat: Implement secret management for environment variables and enhance systemd unit generation

This commit is contained in:
2026-06-27 12:47:05 -07:00
parent 51fdce7eea
commit 3f1b2e6f56
9 changed files with 431 additions and 33 deletions

View File

@@ -8,6 +8,7 @@ import pytest
from castle_core.config import (
CastleConfig,
load_config,
resolve_env_split,
resolve_env_vars,
save_config,
)
@@ -151,3 +152,71 @@ class TestResolveEnvVars:
env = {"API_KEY": "${secret:NONEXISTENT}"}
resolved = resolve_env_vars(env)
assert resolved["API_KEY"] == "<MISSING_SECRET:NONEXISTENT>"
class TestResolveEnvSplit:
"""Tests for the secret/plain partition used to keep secrets out of units."""
def _secrets(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, **vals: str):
secrets_dir = tmp_path / "secrets"
secrets_dir.mkdir()
for name, val in vals.items():
(secrets_dir / name).write_text(val + "\n")
monkeypatch.setattr("castle_core.config.SECRETS_DIR", secrets_dir)
def test_plain_only(self) -> None:
plain, secret = resolve_env_split({"PORT": "9001", "URL": "http://x"})
assert plain == {"PORT": "9001", "URL": "http://x"}
assert secret == {}
def test_context_placeholders_are_plain(self) -> None:
plain, secret = resolve_env_split(
{"P": "${port}", "D": "${data_dir}"}, {"port": "9001", "data_dir": "/d"}
)
assert plain == {"P": "9001", "D": "/d"}
assert secret == {}
def test_pure_secret_partitioned(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
self._secrets(tmp_path, monkeypatch, API_KEY="sk-123")
plain, secret = resolve_env_split({"API_KEY": "${secret:API_KEY}"})
assert plain == {}
assert secret == {"API_KEY": "sk-123"}
def test_composite_secret_partitioned(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A value embedding a secret is secret-bearing; the whole value resolves."""
self._secrets(tmp_path, monkeypatch, NEO4J_PASSWORD="pw")
plain, secret = resolve_env_split(
{"NEO4J_AUTH": "neo4j/${secret:NEO4J_PASSWORD}"}
)
assert plain == {}
assert secret == {"NEO4J_AUTH": "neo4j/pw"}
def test_mixed(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
self._secrets(tmp_path, monkeypatch, K="v")
plain, secret = resolve_env_split(
{"PORT": "${port}", "K": "${secret:K}"}, {"port": "9001"}
)
assert plain == {"PORT": "9001"}
assert secret == {"K": "v"}
def test_missing_secret_still_partitioned(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
self._secrets(tmp_path, monkeypatch)
plain, secret = resolve_env_split({"K": "${secret:NOPE}"})
assert plain == {}
assert secret == {"K": "<MISSING_SECRET:NOPE>"}
def test_resolve_env_vars_matches_merged_split(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The flat wrapper equals the merged split and preserves key order."""
self._secrets(tmp_path, monkeypatch, K="v")
env = {"PORT": "${port}", "K": "${secret:K}", "Z": "lit"}
flat = resolve_env_vars(env, {"port": "9001"})
assert flat == {"PORT": "9001", "K": "v", "Z": "lit"}
assert list(flat.keys()) == ["PORT", "K", "Z"]

View File

@@ -6,7 +6,7 @@ from pathlib import Path
from unittest.mock import patch
from castle_core.deploy import _build_run_cmd
from castle_core.manifest import RunPython
from castle_core.manifest import RunContainer, RunPython
def test_python_runner_uses_uv_run_from_source(tmp_path: Path) -> None:
@@ -35,7 +35,9 @@ def test_python_runner_appends_args(tmp_path: Path) -> None:
def test_python_runner_falls_back_to_path_without_source() -> None:
"""No resolvable source → PATH lookup of the script (no uv run)."""
run = RunPython(runner="python", program="my-svc")
with patch("castle_core.deploy.shutil.which", return_value="/home/u/.local/bin/my-svc"):
with patch(
"castle_core.deploy.shutil.which", return_value="/home/u/.local/bin/my-svc"
):
cmd = _build_run_cmd("my-svc", run, {}, [], source_dir=None)
assert cmd == ["/home/u/.local/bin/my-svc"]
@@ -48,3 +50,25 @@ def test_python_runner_warns_when_unresolvable() -> None:
cmd = _build_run_cmd("my-svc", run, {}, messages, source_dir=None)
assert cmd == ["my-svc"]
assert any("my-svc" in m for m in messages)
def test_container_secrets_use_env_file_not_argv() -> None:
"""Secrets go through --env-file; plain vars stay as -e; no secret in argv."""
run = RunContainer(runner="container", image="img:latest", env={"PLAIN": "1"})
env_file = Path("/home/u/.castle/secrets/env/castle-svc.service.env")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("svc", run, {"PORT": "9001"}, [], secret_env_file=env_file)
joined = " ".join(cmd)
assert "--env-file" in cmd
assert str(env_file) in cmd
# plain (non-secret) vars are still inlined as -e
assert "-e" in cmd and "PORT=9001" in joined and "PLAIN=1" in joined
# no resolved secret value leaks into argv (only the file path is referenced)
assert "SECRET=" not in joined
def test_container_without_secrets_has_no_env_file() -> None:
run = RunContainer(runner="container", image="img:latest")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("svc", run, {}, [], secret_env_file=None)
assert "--env-file" not in cmd

View File

@@ -0,0 +1,87 @@
"""Tests for the generated secret env file and its registry visibility."""
from __future__ import annotations
import stat
from pathlib import Path
import pytest
import castle_core.deploy as deploy
from castle_core.registry import (
Deployment,
NodeConfig,
NodeRegistry,
load_registry,
save_registry,
)
@pytest.fixture
def secret_env_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Redirect the secret-env file location into a temp dir."""
d = tmp_path / "secrets" / "env"
monkeypatch.setattr(deploy, "SECRET_ENV_DIR", d)
monkeypatch.setattr(
deploy, "secret_env_path", lambda name: d / f"castle-{name}.service.env"
)
return d
class TestWriteSecretEnvFile:
def test_writes_mode_0600_in_0700_dir(self, secret_env_dir: Path) -> None:
path = deploy._write_secret_env_file("svc", {"API_KEY": "sk-123"})
assert path is not None and path.exists()
assert stat.S_IMODE(path.stat().st_mode) == 0o600
assert stat.S_IMODE(secret_env_dir.stat().st_mode) == 0o700
def test_content_format(self, secret_env_dir: Path) -> None:
path = deploy._write_secret_env_file(
"svc", {"A": "1", "NEO4J_AUTH": "neo4j/pw"}
)
assert path is not None
assert path.read_text() == "A=1\nNEO4J_AUTH=neo4j/pw\n"
def test_empty_unlinks_and_returns_none(self, secret_env_dir: Path) -> None:
path = deploy.secret_env_path("svc")
secret_env_dir.mkdir(parents=True)
path.write_text("STALE=1\n")
result = deploy._write_secret_env_file("svc", {})
assert result is None
assert not path.exists()
def test_rewrite_truncates_and_fixes_mode(self, secret_env_dir: Path) -> None:
deploy._write_secret_env_file("svc", {"A": "old", "B": "x"})
path = deploy._write_secret_env_file("svc", {"A": "new"})
assert path is not None
assert path.read_text() == "A=new\n"
assert stat.S_IMODE(path.stat().st_mode) == 0o600
class TestRegistrySecretKeys:
def test_round_trip_persists_keys_only(self, tmp_path: Path) -> None:
reg_path = tmp_path / "registry.yaml"
registry = NodeRegistry(
node=NodeConfig(hostname="h"),
deployed={
"svc": Deployment(
runner="container",
run_cmd=["docker", "run"],
env={"PORT": "9001"},
secret_env_keys=["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
managed=True,
)
},
)
save_registry(registry, reg_path)
text = reg_path.read_text()
assert "ANTHROPIC_API_KEY" in text # names are fine
assert "sk-ant" not in text # but no values ever
loaded = load_registry(reg_path)
assert loaded.deployed["svc"].secret_env_keys == [
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
]
assert loaded.deployed["svc"].env == {"PORT": "9001"}

View File

@@ -2,9 +2,13 @@
from __future__ import annotations
from pathlib import Path
from castle_core.generators.systemd import (
generate_timer,
generate_unit_from_deployed,
secret_env_path,
unit_env_file,
unit_name,
)
from castle_core.registry import Deployment
@@ -82,7 +86,10 @@ class TestUnitFromDeployed:
deployed = Deployment(
runner="python",
run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"],
env={"MY_SVC_PORT": "9001", "MY_SVC_DATA_DIR": "/home/user/.castle/data/my-svc"},
env={
"MY_SVC_PORT": "9001",
"MY_SVC_DATA_DIR": "/home/user/.castle/data/my-svc",
},
description="My service",
)
unit = generate_unit_from_deployed("my-svc", deployed)
@@ -118,6 +125,70 @@ class TestUnitFromDeployed:
assert "/data/repos/" not in unit
class TestSecretEnvFile:
"""Secrets are referenced via EnvironmentFile=, never inlined."""
def test_environment_file_added_for_simple_unit(self) -> None:
deployed = Deployment(
runner="python",
run_cmd=["/uv", "run", "my-svc"],
env={"PORT": "9001"},
secret_env_keys=["API_KEY"],
)
path = Path("/home/u/.castle/secrets/env/castle-my-svc.service.env")
unit = generate_unit_from_deployed("my-svc", deployed, env_file=path)
assert f"EnvironmentFile={path}" in unit
# fail-loud: no '-' prefix
assert f"EnvironmentFile=-{path}" not in unit
def test_environment_file_added_for_oneshot_job(self) -> None:
deployed = Deployment(
runner="command",
run_cmd=["/bin/job"],
env={},
schedule="0 2 * * *",
secret_env_keys=["TOKEN"],
)
path = Path("/home/u/.castle/secrets/env/castle-my-job.service.env")
unit = generate_unit_from_deployed("my-job", deployed, env_file=path)
assert "Type=oneshot" in unit
assert f"EnvironmentFile={path}" in unit
def test_no_environment_file_when_none(self) -> None:
deployed = Deployment(runner="python", run_cmd=["/uv", "run", "x"], env={})
unit = generate_unit_from_deployed("x", deployed, env_file=None)
assert "EnvironmentFile" not in unit
def test_secret_values_never_in_unit(self) -> None:
"""The unit references the file path; resolved secret values never appear."""
deployed = Deployment(
runner="python",
run_cmd=["/uv", "run", "x"],
env={"PORT": "9001"},
secret_env_keys=["API_KEY"],
)
path = secret_env_path("x")
unit = generate_unit_from_deployed("x", deployed, env_file=path)
assert "sk-secret" not in unit # value is in the file, not the unit
class TestUnitEnvFile:
"""unit_env_file decides which runners get an EnvironmentFile= path."""
def test_none_without_secrets(self) -> None:
d = Deployment(runner="python", run_cmd=[], env={}, secret_env_keys=[])
assert unit_env_file(d, "x") is None
def test_path_for_python_with_secrets(self) -> None:
d = Deployment(runner="python", run_cmd=[], env={}, secret_env_keys=["K"])
assert unit_env_file(d, "x") == secret_env_path("x")
def test_none_for_container(self) -> None:
"""Containers load secrets via docker --env-file, not systemd."""
d = Deployment(runner="container", run_cmd=[], env={}, secret_env_keys=["K"])
assert unit_env_file(d, "x") is None
class TestGenerateTimer:
"""Tests for timer generation from schedule strings."""