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:
0
sunbeam/tests/__init__.py
Normal file
0
sunbeam/tests/__init__.py
Normal file
191
sunbeam/tests/test_cli.py
Normal file
191
sunbeam/tests/test_cli.py
Normal 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
108
sunbeam/tests/test_kube.py
Normal 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"))
|
||||
93
sunbeam/tests/test_secrets.py
Normal file
93
sunbeam/tests/test_secrets.py
Normal 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()
|
||||
128
sunbeam/tests/test_services.py
Normal file
128
sunbeam/tests/test_services.py
Normal 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
162
sunbeam/tests/test_tools.py
Normal 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)))
|
||||
Reference in New Issue
Block a user