feat: Implement secret management for environment variables and enhance systemd unit generation
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
87
core/tests/test_deploy_secret_env.py
Normal file
87
core/tests/test_deploy_secret_env.py
Normal 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"}
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user