feat: initial sunbeam CLI package

stdlib-only Python CLI replacing infrastructure/scripts/sunbeam.py.
Verbs: up, down, status, apply, seed, verify, logs, restart, get,
build, mirror, bootstrap. Service scoping via ns/name target syntax.
Auto-bundled kubectl/kustomize/helm (SHA256-verified, cached in
~/.local/share/sunbeam/bin). 63 unittest tests, all passing.
This commit is contained in:
2026-03-02 20:59:57 +00:00
commit cdc109d728
20 changed files with 2803 additions and 0 deletions

View File

191
sunbeam/tests/test_cli.py Normal file
View File

@@ -0,0 +1,191 @@
"""Tests for CLI routing and argument validation."""
import sys
import unittest
from unittest.mock import MagicMock, patch
import argparse
class TestArgParsing(unittest.TestCase):
"""Test that argparse parses arguments correctly."""
def _parse(self, argv):
"""Parse argv using the same parser as main(), return args namespace."""
parser = argparse.ArgumentParser(prog="sunbeam")
sub = parser.add_subparsers(dest="verb", metavar="verb")
sub.add_parser("up")
sub.add_parser("down")
p_status = sub.add_parser("status")
p_status.add_argument("target", nargs="?", default=None)
sub.add_parser("apply")
sub.add_parser("seed")
sub.add_parser("verify")
p_logs = sub.add_parser("logs")
p_logs.add_argument("target")
p_logs.add_argument("-f", "--follow", action="store_true")
p_get = sub.add_parser("get")
p_get.add_argument("target")
p_get.add_argument("-o", "--output", default="yaml", choices=["yaml", "json", "wide"])
p_restart = sub.add_parser("restart")
p_restart.add_argument("target", nargs="?", default=None)
p_build = sub.add_parser("build")
p_build.add_argument("what", choices=["proxy"])
sub.add_parser("mirror")
sub.add_parser("bootstrap")
return parser.parse_args(argv)
def test_up(self):
args = self._parse(["up"])
self.assertEqual(args.verb, "up")
def test_status_no_target(self):
args = self._parse(["status"])
self.assertEqual(args.verb, "status")
self.assertIsNone(args.target)
def test_status_with_namespace(self):
args = self._parse(["status", "ory"])
self.assertEqual(args.verb, "status")
self.assertEqual(args.target, "ory")
def test_logs_no_follow(self):
args = self._parse(["logs", "ory/kratos"])
self.assertEqual(args.verb, "logs")
self.assertEqual(args.target, "ory/kratos")
self.assertFalse(args.follow)
def test_logs_follow_short(self):
args = self._parse(["logs", "ory/kratos", "-f"])
self.assertTrue(args.follow)
def test_logs_follow_long(self):
args = self._parse(["logs", "ory/kratos", "--follow"])
self.assertTrue(args.follow)
def test_build_proxy(self):
args = self._parse(["build", "proxy"])
self.assertEqual(args.what, "proxy")
def test_build_invalid_target(self):
with self.assertRaises(SystemExit):
self._parse(["build", "notavalidtarget"])
def test_get_with_target(self):
args = self._parse(["get", "ory/kratos-abc"])
self.assertEqual(args.verb, "get")
self.assertEqual(args.target, "ory/kratos-abc")
self.assertEqual(args.output, "yaml")
def test_get_json_output(self):
args = self._parse(["get", "ory/kratos-abc", "-o", "json"])
self.assertEqual(args.output, "json")
def test_get_invalid_output_format(self):
with self.assertRaises(SystemExit):
self._parse(["get", "ory/kratos-abc", "-o", "toml"])
def test_no_args_verb_is_none(self):
args = self._parse([])
self.assertIsNone(args.verb)
class TestCliDispatch(unittest.TestCase):
"""Test that main() dispatches to the correct command function."""
def test_no_verb_exits_0(self):
with patch.object(sys, "argv", ["sunbeam"]):
from sunbeam import cli
with self.assertRaises(SystemExit) as ctx:
cli.main()
self.assertEqual(ctx.exception.code, 0)
def test_unknown_verb_exits_nonzero(self):
with patch.object(sys, "argv", ["sunbeam", "unknown-verb"]):
from sunbeam import cli
with self.assertRaises(SystemExit) as ctx:
cli.main()
self.assertNotEqual(ctx.exception.code, 0)
def test_up_calls_cmd_up(self):
mock_up = MagicMock()
with patch.object(sys, "argv", ["sunbeam", "up"]):
with patch.dict("sys.modules", {"sunbeam.cluster": MagicMock(cmd_up=mock_up)}):
import importlib
import sunbeam.cli as cli_mod
importlib.reload(cli_mod)
try:
cli_mod.main()
except SystemExit:
pass
mock_up.assert_called_once()
def test_status_no_target(self):
mock_status = MagicMock()
with patch.object(sys, "argv", ["sunbeam", "status"]):
with patch.dict("sys.modules", {"sunbeam.services": MagicMock(cmd_status=mock_status)}):
import importlib, sunbeam.cli as cli_mod
importlib.reload(cli_mod)
try:
cli_mod.main()
except SystemExit:
pass
mock_status.assert_called_once_with(None)
def test_status_with_namespace(self):
mock_status = MagicMock()
with patch.object(sys, "argv", ["sunbeam", "status", "ory"]):
with patch.dict("sys.modules", {"sunbeam.services": MagicMock(cmd_status=mock_status)}):
import importlib, sunbeam.cli as cli_mod
importlib.reload(cli_mod)
try:
cli_mod.main()
except SystemExit:
pass
mock_status.assert_called_once_with("ory")
def test_logs_with_target(self):
mock_logs = MagicMock()
with patch.object(sys, "argv", ["sunbeam", "logs", "ory/kratos"]):
with patch.dict("sys.modules", {"sunbeam.services": MagicMock(cmd_logs=mock_logs)}):
import importlib, sunbeam.cli as cli_mod
importlib.reload(cli_mod)
try:
cli_mod.main()
except SystemExit:
pass
mock_logs.assert_called_once_with("ory/kratos", follow=False)
def test_logs_follow_flag(self):
mock_logs = MagicMock()
with patch.object(sys, "argv", ["sunbeam", "logs", "ory/kratos", "-f"]):
with patch.dict("sys.modules", {"sunbeam.services": MagicMock(cmd_logs=mock_logs)}):
import importlib, sunbeam.cli as cli_mod
importlib.reload(cli_mod)
try:
cli_mod.main()
except SystemExit:
pass
mock_logs.assert_called_once_with("ory/kratos", follow=True)
def test_get_dispatches_with_target_and_output(self):
mock_get = MagicMock()
with patch.object(sys, "argv", ["sunbeam", "get", "ory/kratos-abc"]):
with patch.dict("sys.modules", {"sunbeam.services": MagicMock(cmd_get=mock_get)}):
import importlib, sunbeam.cli as cli_mod
importlib.reload(cli_mod)
try:
cli_mod.main()
except SystemExit:
pass
mock_get.assert_called_once_with("ory/kratos-abc", output="yaml")
def test_build_proxy(self):
mock_build = MagicMock()
with patch.object(sys, "argv", ["sunbeam", "build", "proxy"]):
with patch.dict("sys.modules", {"sunbeam.images": MagicMock(cmd_build=mock_build)}):
import importlib, sunbeam.cli as cli_mod
importlib.reload(cli_mod)
try:
cli_mod.main()
except SystemExit:
pass
mock_build.assert_called_once_with("proxy")

108
sunbeam/tests/test_kube.py Normal file
View File

@@ -0,0 +1,108 @@
"""Tests for kube.py — domain substitution, target parsing, kubectl wrappers."""
import unittest
from unittest.mock import MagicMock, patch
class TestParseTarget(unittest.TestCase):
def setUp(self):
from sunbeam.kube import parse_target
self.parse = parse_target
def test_none(self):
self.assertEqual(self.parse(None), (None, None))
def test_namespace_only(self):
self.assertEqual(self.parse("ory"), ("ory", None))
def test_namespace_and_name(self):
self.assertEqual(self.parse("ory/kratos"), ("ory", "kratos"))
def test_too_many_parts_raises(self):
with self.assertRaises(ValueError):
self.parse("too/many/parts")
def test_empty_string(self):
result = self.parse("")
self.assertEqual(result, ("", None))
class TestDomainReplace(unittest.TestCase):
def setUp(self):
from sunbeam.kube import domain_replace
self.replace = domain_replace
def test_single_occurrence(self):
result = self.replace("src.DOMAIN_SUFFIX/foo", "192.168.1.1.sslip.io")
self.assertEqual(result, "src.192.168.1.1.sslip.io/foo")
def test_multiple_occurrences(self):
text = "DOMAIN_SUFFIX and DOMAIN_SUFFIX"
result = self.replace(text, "x.sslip.io")
self.assertEqual(result, "x.sslip.io and x.sslip.io")
def test_no_occurrence(self):
result = self.replace("no match here", "x.sslip.io")
self.assertEqual(result, "no match here")
class TestKustomizeBuild(unittest.TestCase):
def test_calls_run_tool_and_applies_domain_replace(self):
from pathlib import Path
mock_result = MagicMock()
mock_result.stdout = "image: src.DOMAIN_SUFFIX/foo\nimage: src.DOMAIN_SUFFIX/bar"
with patch("sunbeam.kube.run_tool", return_value=mock_result) as mock_rt:
from sunbeam.kube import kustomize_build
result = kustomize_build(Path("/some/overlay"), "192.168.1.1.sslip.io")
mock_rt.assert_called_once()
call_args = mock_rt.call_args[0]
self.assertEqual(call_args[0], "kustomize")
self.assertIn("build", call_args)
self.assertIn("--enable-helm", call_args)
self.assertIn("192.168.1.1.sslip.io", result)
self.assertNotIn("DOMAIN_SUFFIX", result)
def test_strips_null_annotations(self):
from pathlib import Path
mock_result = MagicMock()
mock_result.stdout = "metadata:\n annotations: null\n name: test"
with patch("sunbeam.kube.run_tool", return_value=mock_result):
from sunbeam.kube import kustomize_build
result = kustomize_build(Path("/overlay"), "x.sslip.io")
self.assertNotIn("annotations: null", result)
class TestKubeWrappers(unittest.TestCase):
def test_kube_passes_context(self):
with patch("sunbeam.kube.run_tool") as mock_rt:
mock_rt.return_value = MagicMock(returncode=0)
from sunbeam.kube import kube
kube("get", "pods")
call_args = mock_rt.call_args[0]
self.assertEqual(call_args[0], "kubectl")
self.assertIn("--context=sunbeam", call_args)
def test_kube_out_returns_stdout_on_success(self):
with patch("sunbeam.kube.run_tool") as mock_rt:
mock_rt.return_value = MagicMock(returncode=0, stdout=" output ")
from sunbeam.kube import kube_out
result = kube_out("get", "pods")
self.assertEqual(result, "output")
def test_kube_out_returns_empty_on_failure(self):
with patch("sunbeam.kube.run_tool") as mock_rt:
mock_rt.return_value = MagicMock(returncode=1, stdout="error text")
from sunbeam.kube import kube_out
result = kube_out("get", "pods")
self.assertEqual(result, "")
def test_kube_ok_returns_true_on_zero(self):
with patch("sunbeam.kube.run_tool") as mock_rt:
mock_rt.return_value = MagicMock(returncode=0)
from sunbeam.kube import kube_ok
self.assertTrue(kube_ok("get", "ns", "default"))
def test_kube_ok_returns_false_on_nonzero(self):
with patch("sunbeam.kube.run_tool") as mock_rt:
mock_rt.return_value = MagicMock(returncode=1)
from sunbeam.kube import kube_ok
self.assertFalse(kube_ok("get", "ns", "missing"))

View File

@@ -0,0 +1,93 @@
"""Tests for secrets.py — seed idempotency, verify flow."""
import base64
import unittest
from unittest.mock import MagicMock, patch, call
class TestSeedIdempotency(unittest.TestCase):
"""_seed_openbao() must read existing values before writing (never rotates)."""
def test_get_or_create_skips_existing(self):
"""If OpenBao already has a value, it's reused not regenerated."""
with patch("sunbeam.secrets._seed_openbao") as mock_seed:
mock_seed.return_value = {
"hydra-system-secret": "existingvalue",
"_ob_pod": "openbao-0",
"_root_token": "token123",
}
from sunbeam import secrets
result = secrets._seed_openbao()
self.assertIn("hydra-system-secret", result)
class TestCmdVerify(unittest.TestCase):
def _mock_kube_out(self, ob_pod="openbao-0", root_token="testtoken", mac=""):
"""Create a side_effect function for kube_out that simulates verify flow."""
encoded_token = base64.b64encode(root_token.encode()).decode()
def side_effect(*args, **kwargs):
args_str = " ".join(str(a) for a in args)
if "app.kubernetes.io/name=openbao" in args_str:
return ob_pod
if "root-token" in args_str:
return encoded_token
if "secretMAC" in args_str:
return mac
if "conditions" in args_str:
return "unknown"
if ".data.test-key" in args_str:
return ""
return ""
return side_effect
def test_verify_cleans_up_on_timeout(self):
"""cmd_verify() must clean up test resources even when VSO doesn't sync."""
kube_out_fn = self._mock_kube_out(mac="") # MAC never set -> timeout
with patch("sunbeam.secrets.kube_out", side_effect=kube_out_fn):
with patch("sunbeam.secrets.kube") as mock_kube:
with patch("sunbeam.secrets.kube_apply"):
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with patch("time.time") as mock_time:
# start=0, first check=0, second check past deadline
mock_time.side_effect = [0, 0, 100]
with patch("time.sleep"):
from sunbeam import secrets
with self.assertRaises(SystemExit):
secrets.cmd_verify()
# Cleanup should have been called (delete calls)
delete_calls = [c for c in mock_kube.call_args_list
if "delete" in str(c)]
self.assertGreater(len(delete_calls), 0)
def test_verify_succeeds_when_synced(self):
"""cmd_verify() succeeds when VSO syncs the secret and value matches."""
# We need a fixed test_value. Patch _secrets.token_urlsafe to return known value.
test_val = "fixed-test-value"
encoded_val = base64.b64encode(test_val.encode()).decode()
encoded_token = base64.b64encode(b"testtoken").decode()
call_count = [0]
def kube_out_fn(*args, **kwargs):
args_str = " ".join(str(a) for a in args)
if "app.kubernetes.io/name=openbao" in args_str:
return "openbao-0"
if "root-token" in args_str:
return encoded_token
if "secretMAC" in args_str:
call_count[0] += 1
return "somemac" if call_count[0] >= 1 else ""
if ".data.test-key" in args_str:
return encoded_val
return ""
with patch("sunbeam.secrets.kube_out", side_effect=kube_out_fn):
with patch("sunbeam.secrets.kube") as mock_kube:
with patch("sunbeam.secrets.kube_apply"):
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with patch("sunbeam.secrets._secrets.token_urlsafe", return_value=test_val):
with patch("time.time", return_value=0):
with patch("time.sleep"):
from sunbeam import secrets
# Should not raise
secrets.cmd_verify()

View File

@@ -0,0 +1,128 @@
"""Tests for services.py — status scoping, log command construction, restart."""
import unittest
from unittest.mock import MagicMock, patch, call
class TestCmdStatus(unittest.TestCase):
def test_all_namespaces_when_no_target(self):
fake_output = (
"ory hydra-abc 1/1 Running 0 1d\n"
"data valkey-xyz 1/1 Running 0 1d\n"
)
with patch("sunbeam.services._capture_out", return_value=fake_output):
from sunbeam import services
services.cmd_status(None)
def test_namespace_scoped(self):
fake_output = "ory kratos-abc 1/1 Running 0 1d\n"
with patch("sunbeam.services._capture_out", return_value=fake_output) as mock_co:
from sunbeam import services
services.cmd_status("ory")
# Should have called _capture_out with -n ory
calls_str = str(mock_co.call_args_list)
self.assertIn("ory", calls_str)
def test_pod_scoped(self):
fake_output = "kratos-abc 1/1 Running 0 1d\n"
with patch("sunbeam.services._capture_out", return_value=fake_output) as mock_co:
from sunbeam import services
services.cmd_status("ory/kratos")
calls_str = str(mock_co.call_args_list)
self.assertIn("ory", calls_str)
self.assertIn("kratos", calls_str)
class TestCmdLogs(unittest.TestCase):
def test_logs_no_follow(self):
with patch("subprocess.Popen") as mock_popen:
mock_proc = MagicMock()
mock_proc.wait.return_value = 0
mock_popen.return_value = mock_proc
with patch("sunbeam.tools.ensure_tool", return_value="/fake/kubectl"):
from sunbeam import services
services.cmd_logs("ory/kratos", follow=False)
args = mock_popen.call_args[0][0]
self.assertIn("-n", args)
self.assertIn("ory", args)
self.assertNotIn("--follow", args)
def test_logs_follow(self):
with patch("subprocess.Popen") as mock_popen:
mock_proc = MagicMock()
mock_proc.wait.return_value = 0
mock_popen.return_value = mock_proc
with patch("sunbeam.tools.ensure_tool", return_value="/fake/kubectl"):
from sunbeam import services
services.cmd_logs("ory/kratos", follow=True)
args = mock_popen.call_args[0][0]
self.assertIn("--follow", args)
def test_logs_requires_service_name(self):
"""Passing just a namespace (no service) should die()."""
with self.assertRaises(SystemExit):
from sunbeam import services
services.cmd_logs("ory", follow=False)
class TestCmdGet(unittest.TestCase):
def test_prints_yaml_for_pod(self):
with patch("sunbeam.services.kube_out", return_value="apiVersion: v1\nkind: Pod") as mock_ko:
from sunbeam import services
services.cmd_get("ory/kratos-abc")
mock_ko.assert_called_once_with("get", "pod", "kratos-abc", "-n", "ory", "-o=yaml")
def test_default_output_is_yaml(self):
with patch("sunbeam.services.kube_out", return_value="kind: Pod"):
from sunbeam import services
# no output kwarg → defaults to yaml
services.cmd_get("ory/kratos-abc")
def test_json_output_format(self):
with patch("sunbeam.services.kube_out", return_value='{"kind":"Pod"}') as mock_ko:
from sunbeam import services
services.cmd_get("ory/kratos-abc", output="json")
mock_ko.assert_called_once_with("get", "pod", "kratos-abc", "-n", "ory", "-o=json")
def test_missing_name_exits(self):
with self.assertRaises(SystemExit):
from sunbeam import services
services.cmd_get("ory") # namespace-only, no pod name
def test_not_found_exits(self):
with patch("sunbeam.services.kube_out", return_value=""):
with self.assertRaises(SystemExit):
from sunbeam import services
services.cmd_get("ory/nonexistent")
class TestCmdRestart(unittest.TestCase):
def test_restart_all(self):
with patch("sunbeam.services.kube") as mock_kube:
from sunbeam import services
services.cmd_restart(None)
# Should restart all SERVICES_TO_RESTART
self.assertGreater(mock_kube.call_count, 0)
def test_restart_namespace_scoped(self):
with patch("sunbeam.services.kube") as mock_kube:
from sunbeam import services
services.cmd_restart("ory")
calls_str = str(mock_kube.call_args_list)
# Should only restart ory/* services
self.assertIn("ory", calls_str)
self.assertNotIn("devtools", calls_str)
def test_restart_specific_service(self):
with patch("sunbeam.services.kube") as mock_kube:
from sunbeam import services
services.cmd_restart("ory/kratos")
# Should restart exactly deployment/kratos in ory
calls_str = str(mock_kube.call_args_list)
self.assertIn("kratos", calls_str)
def test_restart_unknown_service_warns(self):
with patch("sunbeam.services.kube") as mock_kube:
from sunbeam import services
services.cmd_restart("nonexistent/nosuch")
# kube should not be called since no match
mock_kube.assert_not_called()

162
sunbeam/tests/test_tools.py Normal file
View File

@@ -0,0 +1,162 @@
"""Tests for tools.py binary bundler."""
import hashlib
import stat
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
import tempfile
import shutil
class TestSha256(unittest.TestCase):
def test_computes_correct_hash(self):
from sunbeam.tools import _sha256
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(b"hello world")
f.flush()
path = Path(f.name)
try:
expected = hashlib.sha256(b"hello world").hexdigest()
self.assertEqual(_sha256(path), expected)
finally:
path.unlink()
class TestEnsureTool(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.cache_patcher = patch("sunbeam.tools.CACHE_DIR", Path(self.tmpdir))
self.cache_patcher.start()
def tearDown(self):
self.cache_patcher.stop()
shutil.rmtree(self.tmpdir, ignore_errors=True)
def test_returns_cached_if_sha_matches(self):
binary_data = b"#!/bin/sh\necho kubectl"
dest = Path(self.tmpdir) / "kubectl"
dest.write_bytes(binary_data)
dest.chmod(dest.stat().st_mode | stat.S_IXUSR)
expected_sha = hashlib.sha256(binary_data).hexdigest()
tools_spec = {"kubectl": {"url": "http://x", "sha256": expected_sha}}
with patch("sunbeam.tools.TOOLS", tools_spec):
from sunbeam import tools
result = tools.ensure_tool("kubectl")
self.assertEqual(result, dest)
def test_returns_cached_if_sha_empty(self):
binary_data = b"#!/bin/sh\necho kubectl"
dest = Path(self.tmpdir) / "kubectl"
dest.write_bytes(binary_data)
dest.chmod(dest.stat().st_mode | stat.S_IXUSR)
tools_spec = {"kubectl": {"url": "http://x", "sha256": ""}}
with patch("sunbeam.tools.TOOLS", tools_spec):
from sunbeam import tools
result = tools.ensure_tool("kubectl")
self.assertEqual(result, dest)
def test_downloads_on_cache_miss(self):
binary_data = b"#!/bin/sh\necho kubectl"
tools_spec = {"kubectl": {"url": "http://example.com/kubectl", "sha256": ""}}
with patch("sunbeam.tools.TOOLS", tools_spec):
with patch("urllib.request.urlopen") as mock_url:
mock_resp = MagicMock()
mock_resp.read.return_value = binary_data
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
mock_url.return_value = mock_resp
from sunbeam import tools
result = tools.ensure_tool("kubectl")
dest = Path(self.tmpdir) / "kubectl"
self.assertTrue(dest.exists())
self.assertEqual(dest.read_bytes(), binary_data)
# Should be executable
self.assertTrue(dest.stat().st_mode & stat.S_IXUSR)
def test_raises_on_sha256_mismatch(self):
binary_data = b"#!/bin/sh\necho fake"
tools_spec = {"kubectl": {
"url": "http://example.com/kubectl",
"sha256": "a" * 64, # wrong hash
}}
with patch("sunbeam.tools.TOOLS", tools_spec):
with patch("urllib.request.urlopen") as mock_url:
mock_resp = MagicMock()
mock_resp.read.return_value = binary_data
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
mock_url.return_value = mock_resp
from sunbeam import tools
with self.assertRaises(RuntimeError) as ctx:
tools.ensure_tool("kubectl")
self.assertIn("SHA256 mismatch", str(ctx.exception))
# Binary should be cleaned up
self.assertFalse((Path(self.tmpdir) / "kubectl").exists())
def test_redownloads_on_sha_mismatch_cached(self):
"""If cached binary has wrong hash, it's deleted and re-downloaded."""
old_data = b"old binary"
new_data = b"new binary"
dest = Path(self.tmpdir) / "kubectl"
dest.write_bytes(old_data)
new_sha = hashlib.sha256(new_data).hexdigest()
tools_spec = {"kubectl": {"url": "http://x/kubectl", "sha256": new_sha}}
with patch("sunbeam.tools.TOOLS", tools_spec):
with patch("urllib.request.urlopen") as mock_url:
mock_resp = MagicMock()
mock_resp.read.return_value = new_data
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
mock_url.return_value = mock_resp
from sunbeam import tools
result = tools.ensure_tool("kubectl")
self.assertEqual(dest.read_bytes(), new_data)
def test_unknown_tool_raises_value_error(self):
from sunbeam import tools
with self.assertRaises(ValueError):
tools.ensure_tool("notarealtool")
class TestRunTool(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.cache_patcher = patch("sunbeam.tools.CACHE_DIR", Path(self.tmpdir))
self.cache_patcher.start()
def tearDown(self):
self.cache_patcher.stop()
shutil.rmtree(self.tmpdir, ignore_errors=True)
def test_kustomize_prepends_cache_dir_to_path(self):
binary_data = b"#!/bin/sh"
dest = Path(self.tmpdir) / "kustomize"
dest.write_bytes(binary_data)
dest.chmod(dest.stat().st_mode | stat.S_IXUSR)
tools_spec = {"kustomize": {"url": "http://x", "sha256": ""}}
with patch("sunbeam.tools.TOOLS", tools_spec):
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
from sunbeam import tools
tools.run_tool("kustomize", "build", ".")
call_kwargs = mock_run.call_args[1]
env = call_kwargs.get("env", {})
self.assertTrue(env.get("PATH", "").startswith(str(self.tmpdir)))
def test_non_kustomize_does_not_modify_path(self):
binary_data = b"#!/bin/sh"
dest = Path(self.tmpdir) / "kubectl"
dest.write_bytes(binary_data)
dest.chmod(dest.stat().st_mode | stat.S_IXUSR)
tools_spec = {"kubectl": {"url": "http://x", "sha256": ""}}
with patch("sunbeam.tools.TOOLS", tools_spec):
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
from sunbeam import tools
import os
original_path = os.environ.get("PATH", "")
tools.run_tool("kubectl", "get", "pods")
call_kwargs = mock_run.call_args[1]
env = call_kwargs.get("env", {})
# PATH should not be modified (starts same as original)
self.assertFalse(env.get("PATH", "").startswith(str(self.tmpdir)))