feat(cli): partial apply with namespace filter
sunbeam apply [namespace] builds the full kustomize overlay (preserving all image substitutions and patches) then filters the output to only resources in the given namespace before applying. Cleanup and ConfigMap restart detection are also scoped to the target namespace. - manifests.py: _filter_by_namespace(), scoped pre_apply_cleanup() - cli.py: namespace positional arg for apply; meet added to build choices - tests: 17 new tests covering filter logic and CLI dispatch
This commit is contained in:
@@ -16,7 +16,10 @@ class TestArgParsing(unittest.TestCase):
|
||||
sub.add_parser("down")
|
||||
p_status = sub.add_parser("status")
|
||||
p_status.add_argument("target", nargs="?", default=None)
|
||||
sub.add_parser("apply")
|
||||
p_apply = sub.add_parser("apply")
|
||||
p_apply.add_argument("namespace", nargs="?", default="")
|
||||
p_apply.add_argument("--domain", default="")
|
||||
p_apply.add_argument("--email", default="")
|
||||
sub.add_parser("seed")
|
||||
sub.add_parser("verify")
|
||||
p_logs = sub.add_parser("logs")
|
||||
@@ -28,11 +31,35 @@ class TestArgParsing(unittest.TestCase):
|
||||
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"])
|
||||
p_build.add_argument("what", choices=["proxy", "integration", "kratos-admin", "meet",
|
||||
"docs-frontend", "people-frontend"])
|
||||
p_build.add_argument("--push", action="store_true")
|
||||
p_build.add_argument("--deploy", action="store_true")
|
||||
sub.add_parser("mirror")
|
||||
sub.add_parser("bootstrap")
|
||||
p_check = sub.add_parser("check")
|
||||
p_check.add_argument("target", nargs="?", default=None)
|
||||
p_user = sub.add_parser("user")
|
||||
user_sub = p_user.add_subparsers(dest="user_action")
|
||||
p_user_list = user_sub.add_parser("list")
|
||||
p_user_list.add_argument("--search", default="")
|
||||
p_user_get = user_sub.add_parser("get")
|
||||
p_user_get.add_argument("target")
|
||||
p_user_create = user_sub.add_parser("create")
|
||||
p_user_create.add_argument("email")
|
||||
p_user_create.add_argument("--name", default="")
|
||||
p_user_create.add_argument("--schema", default="default")
|
||||
p_user_delete = user_sub.add_parser("delete")
|
||||
p_user_delete.add_argument("target")
|
||||
p_user_recover = user_sub.add_parser("recover")
|
||||
p_user_recover.add_argument("target")
|
||||
p_user_disable = user_sub.add_parser("disable")
|
||||
p_user_disable.add_argument("target")
|
||||
p_user_enable = user_sub.add_parser("enable")
|
||||
p_user_enable.add_argument("target")
|
||||
p_user_set_pw = user_sub.add_parser("set-password")
|
||||
p_user_set_pw.add_argument("target")
|
||||
p_user_set_pw.add_argument("password")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
def test_up(self):
|
||||
@@ -66,11 +93,55 @@ class TestArgParsing(unittest.TestCase):
|
||||
def test_build_proxy(self):
|
||||
args = self._parse(["build", "proxy"])
|
||||
self.assertEqual(args.what, "proxy")
|
||||
self.assertFalse(args.push)
|
||||
self.assertFalse(args.deploy)
|
||||
|
||||
def test_build_integration(self):
|
||||
args = self._parse(["build", "integration"])
|
||||
self.assertEqual(args.what, "integration")
|
||||
|
||||
def test_build_push_flag(self):
|
||||
args = self._parse(["build", "proxy", "--push"])
|
||||
self.assertTrue(args.push)
|
||||
self.assertFalse(args.deploy)
|
||||
|
||||
def test_build_deploy_flag(self):
|
||||
args = self._parse(["build", "proxy", "--deploy"])
|
||||
self.assertFalse(args.push)
|
||||
self.assertTrue(args.deploy)
|
||||
|
||||
def test_build_invalid_target(self):
|
||||
with self.assertRaises(SystemExit):
|
||||
self._parse(["build", "notavalidtarget"])
|
||||
|
||||
def test_user_set_password(self):
|
||||
args = self._parse(["user", "set-password", "admin@example.com", "hunter2"])
|
||||
self.assertEqual(args.verb, "user")
|
||||
self.assertEqual(args.user_action, "set-password")
|
||||
self.assertEqual(args.target, "admin@example.com")
|
||||
self.assertEqual(args.password, "hunter2")
|
||||
|
||||
def test_user_disable(self):
|
||||
args = self._parse(["user", "disable", "admin@example.com"])
|
||||
self.assertEqual(args.user_action, "disable")
|
||||
self.assertEqual(args.target, "admin@example.com")
|
||||
|
||||
def test_user_enable(self):
|
||||
args = self._parse(["user", "enable", "admin@example.com"])
|
||||
self.assertEqual(args.user_action, "enable")
|
||||
self.assertEqual(args.target, "admin@example.com")
|
||||
|
||||
def test_user_list_search(self):
|
||||
args = self._parse(["user", "list", "--search", "sienna"])
|
||||
self.assertEqual(args.user_action, "list")
|
||||
self.assertEqual(args.search, "sienna")
|
||||
|
||||
def test_user_create(self):
|
||||
args = self._parse(["user", "create", "x@example.com", "--name", "X Y"])
|
||||
self.assertEqual(args.user_action, "create")
|
||||
self.assertEqual(args.email, "x@example.com")
|
||||
self.assertEqual(args.name, "X Y")
|
||||
|
||||
def test_get_with_target(self):
|
||||
args = self._parse(["get", "ory/kratos-abc"])
|
||||
self.assertEqual(args.verb, "get")
|
||||
@@ -100,6 +171,24 @@ class TestArgParsing(unittest.TestCase):
|
||||
self.assertEqual(args.verb, "check")
|
||||
self.assertEqual(args.target, "lasuite/people")
|
||||
|
||||
def test_apply_no_namespace(self):
|
||||
args = self._parse(["apply"])
|
||||
self.assertEqual(args.verb, "apply")
|
||||
self.assertEqual(args.namespace, "")
|
||||
|
||||
def test_apply_with_namespace(self):
|
||||
args = self._parse(["apply", "lasuite"])
|
||||
self.assertEqual(args.verb, "apply")
|
||||
self.assertEqual(args.namespace, "lasuite")
|
||||
|
||||
def test_apply_ingress_namespace(self):
|
||||
args = self._parse(["apply", "ingress"])
|
||||
self.assertEqual(args.namespace, "ingress")
|
||||
|
||||
def test_build_meet(self):
|
||||
args = self._parse(["build", "meet"])
|
||||
self.assertEqual(args.what, "meet")
|
||||
|
||||
def test_no_args_verb_is_none(self):
|
||||
args = self._parse([])
|
||||
self.assertIsNone(args.verb)
|
||||
@@ -205,7 +294,122 @@ class TestCliDispatch(unittest.TestCase):
|
||||
cli_mod.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
mock_build.assert_called_once_with("proxy")
|
||||
mock_build.assert_called_once_with("proxy", push=False, deploy=False)
|
||||
|
||||
def test_build_with_push_flag(self):
|
||||
mock_build = MagicMock()
|
||||
with patch.object(sys, "argv", ["sunbeam", "build", "integration", "--push"]):
|
||||
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("integration", push=True, deploy=False)
|
||||
|
||||
def test_build_with_deploy_flag_implies_push(self):
|
||||
mock_build = MagicMock()
|
||||
with patch.object(sys, "argv", ["sunbeam", "build", "proxy", "--deploy"]):
|
||||
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", push=True, deploy=True)
|
||||
|
||||
def test_user_set_password_dispatches(self):
|
||||
mock_set_pw = MagicMock()
|
||||
mock_users = MagicMock(
|
||||
cmd_user_list=MagicMock(), cmd_user_get=MagicMock(),
|
||||
cmd_user_create=MagicMock(), cmd_user_delete=MagicMock(),
|
||||
cmd_user_recover=MagicMock(), cmd_user_disable=MagicMock(),
|
||||
cmd_user_enable=MagicMock(), cmd_user_set_password=mock_set_pw,
|
||||
)
|
||||
with patch.object(sys, "argv", ["sunbeam", "user", "set-password",
|
||||
"admin@sunbeam.pt", "s3cr3t"]):
|
||||
with patch.dict("sys.modules", {"sunbeam.users": mock_users}):
|
||||
import importlib, sunbeam.cli as cli_mod
|
||||
importlib.reload(cli_mod)
|
||||
try:
|
||||
cli_mod.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
mock_set_pw.assert_called_once_with("admin@sunbeam.pt", "s3cr3t")
|
||||
|
||||
def test_user_disable_dispatches(self):
|
||||
mock_disable = MagicMock()
|
||||
mock_users = MagicMock(
|
||||
cmd_user_list=MagicMock(), cmd_user_get=MagicMock(),
|
||||
cmd_user_create=MagicMock(), cmd_user_delete=MagicMock(),
|
||||
cmd_user_recover=MagicMock(), cmd_user_disable=mock_disable,
|
||||
cmd_user_enable=MagicMock(), cmd_user_set_password=MagicMock(),
|
||||
)
|
||||
with patch.object(sys, "argv", ["sunbeam", "user", "disable", "x@sunbeam.pt"]):
|
||||
with patch.dict("sys.modules", {"sunbeam.users": mock_users}):
|
||||
import importlib, sunbeam.cli as cli_mod
|
||||
importlib.reload(cli_mod)
|
||||
try:
|
||||
cli_mod.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
mock_disable.assert_called_once_with("x@sunbeam.pt")
|
||||
|
||||
def test_user_enable_dispatches(self):
|
||||
mock_enable = MagicMock()
|
||||
mock_users = MagicMock(
|
||||
cmd_user_list=MagicMock(), cmd_user_get=MagicMock(),
|
||||
cmd_user_create=MagicMock(), cmd_user_delete=MagicMock(),
|
||||
cmd_user_recover=MagicMock(), cmd_user_disable=MagicMock(),
|
||||
cmd_user_enable=mock_enable, cmd_user_set_password=MagicMock(),
|
||||
)
|
||||
with patch.object(sys, "argv", ["sunbeam", "user", "enable", "x@sunbeam.pt"]):
|
||||
with patch.dict("sys.modules", {"sunbeam.users": mock_users}):
|
||||
import importlib, sunbeam.cli as cli_mod
|
||||
importlib.reload(cli_mod)
|
||||
try:
|
||||
cli_mod.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
mock_enable.assert_called_once_with("x@sunbeam.pt")
|
||||
|
||||
def test_apply_full_dispatches_without_namespace(self):
|
||||
mock_apply = MagicMock()
|
||||
with patch.object(sys, "argv", ["sunbeam", "apply"]):
|
||||
with patch.dict("sys.modules", {"sunbeam.manifests": MagicMock(cmd_apply=mock_apply)}):
|
||||
import importlib, sunbeam.cli as cli_mod
|
||||
importlib.reload(cli_mod)
|
||||
try:
|
||||
cli_mod.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
mock_apply.assert_called_once_with(env="local", domain="", email="", namespace="")
|
||||
|
||||
def test_apply_partial_passes_namespace(self):
|
||||
mock_apply = MagicMock()
|
||||
with patch.object(sys, "argv", ["sunbeam", "apply", "lasuite"]):
|
||||
with patch.dict("sys.modules", {"sunbeam.manifests": MagicMock(cmd_apply=mock_apply)}):
|
||||
import importlib, sunbeam.cli as cli_mod
|
||||
importlib.reload(cli_mod)
|
||||
try:
|
||||
cli_mod.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
mock_apply.assert_called_once_with(env="local", domain="", email="", namespace="lasuite")
|
||||
|
||||
def test_build_meet_dispatches(self):
|
||||
mock_build = MagicMock()
|
||||
with patch.object(sys, "argv", ["sunbeam", "build", "meet"]):
|
||||
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("meet", push=False, deploy=False)
|
||||
|
||||
def test_check_no_target(self):
|
||||
mock_check = MagicMock()
|
||||
|
||||
99
sunbeam/tests/test_manifests.py
Normal file
99
sunbeam/tests/test_manifests.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Tests for manifests.py — primarily _filter_by_namespace."""
|
||||
import unittest
|
||||
|
||||
from sunbeam.manifests import _filter_by_namespace
|
||||
|
||||
|
||||
MULTI_DOC = """\
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: meet-config
|
||||
namespace: lasuite
|
||||
data:
|
||||
FOO: bar
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: meet-backend
|
||||
namespace: lasuite
|
||||
spec:
|
||||
replicas: 1
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: lasuite
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: pingora-config
|
||||
namespace: ingress
|
||||
data:
|
||||
config.toml: |
|
||||
hello
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: pingora
|
||||
namespace: ingress
|
||||
spec:
|
||||
replicas: 1
|
||||
"""
|
||||
|
||||
|
||||
class TestFilterByNamespace(unittest.TestCase):
|
||||
|
||||
def test_keeps_matching_namespace(self):
|
||||
result = _filter_by_namespace(MULTI_DOC, "lasuite")
|
||||
self.assertIn("name: meet-config", result)
|
||||
self.assertIn("name: meet-backend", result)
|
||||
|
||||
def test_excludes_other_namespaces(self):
|
||||
result = _filter_by_namespace(MULTI_DOC, "lasuite")
|
||||
self.assertNotIn("namespace: ingress", result)
|
||||
self.assertNotIn("name: pingora-config", result)
|
||||
self.assertNotIn("name: pingora", result)
|
||||
|
||||
def test_includes_namespace_resource_itself(self):
|
||||
result = _filter_by_namespace(MULTI_DOC, "lasuite")
|
||||
self.assertIn("kind: Namespace", result)
|
||||
|
||||
def test_ingress_filter(self):
|
||||
result = _filter_by_namespace(MULTI_DOC, "ingress")
|
||||
self.assertIn("name: pingora-config", result)
|
||||
self.assertIn("name: pingora", result)
|
||||
self.assertNotIn("namespace: lasuite", result)
|
||||
|
||||
def test_unknown_namespace_returns_empty(self):
|
||||
result = _filter_by_namespace(MULTI_DOC, "nonexistent")
|
||||
self.assertEqual(result.strip(), "")
|
||||
|
||||
def test_empty_input_returns_empty(self):
|
||||
result = _filter_by_namespace("", "lasuite")
|
||||
self.assertEqual(result.strip(), "")
|
||||
|
||||
def test_result_is_valid_multidoc_yaml(self):
|
||||
# Each non-empty doc in the result should start with '---'
|
||||
result = _filter_by_namespace(MULTI_DOC, "lasuite")
|
||||
self.assertTrue(result.startswith("---"))
|
||||
|
||||
def test_does_not_include_namespace_resource_for_wrong_ns(self):
|
||||
# The lasuite Namespace CR should NOT appear in an ingress-filtered result
|
||||
result = _filter_by_namespace(MULTI_DOC, "ingress")
|
||||
# There's no ingress Namespace CR in the fixture, so kind: Namespace should be absent
|
||||
self.assertNotIn("kind: Namespace", result)
|
||||
|
||||
def test_single_doc_matching(self):
|
||||
doc = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: x\n namespace: ory\n"
|
||||
result = _filter_by_namespace(doc, "ory")
|
||||
self.assertIn("name: x", result)
|
||||
|
||||
def test_single_doc_not_matching(self):
|
||||
doc = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: x\n namespace: ory\n"
|
||||
result = _filter_by_namespace(doc, "lasuite")
|
||||
self.assertEqual(result.strip(), "")
|
||||
Reference in New Issue
Block a user