diff --git a/sunbeam/cli.py b/sunbeam/cli.py index e6d94dc..d2eddb0 100644 --- a/sunbeam/cli.py +++ b/sunbeam/cli.py @@ -30,6 +30,7 @@ def main() -> None: "--email", default="", help="ACME email for cert-manager (e.g. ops@sunbeam.pt)", ) + sub = parser.add_subparsers(dest="verb", metavar="verb") # sunbeam up @@ -78,7 +79,10 @@ def main() -> None: p_build = sub.add_parser("build", help="Build an artifact (add --push to push, --deploy to apply+rollout)") p_build.add_argument("what", choices=["proxy", "integration", "kratos-admin", "meet", - "docs-frontend", "people-frontend"], + "docs-frontend", "people-frontend", "people", + "messages", "messages-backend", "messages-frontend", + "messages-mta-in", "messages-mta-out", + "messages-mpa", "messages-socks-proxy"], help="What to build") p_build.add_argument("--push", action="store_true", help="Push image to registry after building") @@ -96,6 +100,23 @@ def main() -> None: # sunbeam bootstrap sub.add_parser("bootstrap", help="Create Gitea orgs/repos; set up Lima registry") + # sunbeam config [args] + p_config = sub.add_parser("config", help="Manage sunbeam configuration") + config_sub = p_config.add_subparsers(dest="config_action", metavar="action") + + # sunbeam config set --host HOST --infra-dir DIR + p_config_set = config_sub.add_parser("set", help="Set configuration values") + p_config_set.add_argument("--host", default="", + help="Production SSH host (e.g. user@server.example.com)") + p_config_set.add_argument("--infra-dir", default="", + help="Infrastructure directory root") + + # sunbeam config get + config_sub.add_parser("get", help="Get current configuration") + + # sunbeam config clear + config_sub.add_parser("clear", help="Clear configuration") + # sunbeam k8s [kubectl args...] — transparent kubectl --context=sunbeam wrapper p_k8s = sub.add_parser("k8s", help="kubectl --context=sunbeam passthrough") p_k8s.add_argument("kubectl_args", nargs=argparse.REMAINDER, @@ -139,18 +160,22 @@ def main() -> None: args = parser.parse_args() + + # Set kubectl context before any kube calls. # For production, also register the SSH host so the tunnel is opened on demand. # SUNBEAM_SSH_HOST env var: e.g. "user@server.example.com" or just "server.example.com" import os from sunbeam.kube import set_context + from sunbeam.config import get_production_host + ctx = args.context or ENV_CONTEXTS.get(args.env, "sunbeam") ssh_host = "" if args.env == "production": - ssh_host = os.environ.get("SUNBEAM_SSH_HOST", "") + ssh_host = get_production_host() if not ssh_host: from sunbeam.output import die - die("SUNBEAM_SSH_HOST must be set for --env production (e.g. user@your-server.example.com)") + die("Production host not configured. Use --host to set it or set SUNBEAM_SSH_HOST environment variable.") set_context(ctx, ssh_host=ssh_host) if args.verb is None: @@ -215,6 +240,41 @@ def main() -> None: from sunbeam.gitea import cmd_bootstrap cmd_bootstrap() + elif args.verb == "config": + from sunbeam.config import ( + SunbeamConfig, load_config, save_config, get_production_host, get_infra_directory + ) + action = getattr(args, "config_action", None) + if action is None: + p_config.print_help() + sys.exit(0) + elif action == "set": + config = SunbeamConfig( + production_host=args.host if args.host else "", + infra_directory=args.infra_dir if args.infra_dir else "" + ) + save_config(config) + elif action == "get": + from sunbeam.output import ok + config = load_config() + ok(f"Production host: {config.production_host or '(not set)'}") + ok(f"Infrastructure directory: {config.infra_directory or '(not set)'}") + + # Also show effective production host (from config or env) + effective_host = get_production_host() + if effective_host: + ok(f"Effective production host: {effective_host}") + elif action == "clear": + import os + config_path = os.path.expanduser("~/.sunbeam.json") + if os.path.exists(config_path): + os.remove(config_path) + from sunbeam.output import ok + ok(f"Configuration cleared from {config_path}") + else: + from sunbeam.output import warn + warn("No configuration file found to clear") + elif args.verb == "k8s": from sunbeam.kube import cmd_k8s sys.exit(cmd_k8s(args.kubectl_args)) diff --git a/sunbeam/config.py b/sunbeam/config.py new file mode 100644 index 0000000..319c384 --- /dev/null +++ b/sunbeam/config.py @@ -0,0 +1,73 @@ +"""Configuration management — load/save ~/.sunbeam.json for production host and infra directory.""" +import json +import os +from pathlib import Path +from typing import Optional + + +CONFIG_PATH = Path.home() / ".sunbeam.json" + + +class SunbeamConfig: + """Sunbeam configuration with production host and infrastructure directory.""" + + def __init__(self, production_host: str = "", infra_directory: str = ""): + self.production_host = production_host + self.infra_directory = infra_directory + + def to_dict(self) -> dict: + """Convert configuration to dictionary for JSON serialization.""" + return { + "production_host": self.production_host, + "infra_directory": self.infra_directory, + } + + @classmethod + def from_dict(cls, data: dict) -> 'SunbeamConfig': + """Create configuration from dictionary.""" + return cls( + production_host=data.get("production_host", ""), + infra_directory=data.get("infra_directory", ""), + ) + + +def load_config() -> SunbeamConfig: + """Load configuration from ~/.sunbeam.json, return empty config if not found.""" + if not CONFIG_PATH.exists(): + return SunbeamConfig() + + try: + with open(CONFIG_PATH, 'r') as f: + data = json.load(f) + return SunbeamConfig.from_dict(data) + except (json.JSONDecodeError, IOError, KeyError) as e: + from sunbeam.output import warn + warn(f"Failed to load config from {CONFIG_PATH}: {e}") + return SunbeamConfig() + + +def save_config(config: SunbeamConfig) -> None: + """Save configuration to ~/.sunbeam.json.""" + try: + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(CONFIG_PATH, 'w') as f: + json.dump(config.to_dict(), f, indent=2) + from sunbeam.output import ok + ok(f"Configuration saved to {CONFIG_PATH}") + except IOError as e: + from sunbeam.output import die + die(f"Failed to save config to {CONFIG_PATH}: {e}") + + +def get_production_host() -> str: + """Get production host from config or SUNBEAM_SSH_HOST environment variable.""" + config = load_config() + if config.production_host: + return config.production_host + return os.environ.get("SUNBEAM_SSH_HOST", "") + + +def get_infra_directory() -> str: + """Get infrastructure directory from config.""" + config = load_config() + return config.infra_directory diff --git a/sunbeam/tests/test_cli.py b/sunbeam/tests/test_cli.py index 5a42e1c..ab4aea2 100644 --- a/sunbeam/tests/test_cli.py +++ b/sunbeam/tests/test_cli.py @@ -32,7 +32,10 @@ class TestArgParsing(unittest.TestCase): p_restart.add_argument("target", nargs="?", default=None) p_build = sub.add_parser("build") p_build.add_argument("what", choices=["proxy", "integration", "kratos-admin", "meet", - "docs-frontend", "people-frontend"]) + "docs-frontend", "people-frontend", "people", + "messages", "messages-backend", "messages-frontend", + "messages-mta-in", "messages-mta-out", + "messages-mpa", "messages-socks-proxy"]) p_build.add_argument("--push", action="store_true") p_build.add_argument("--deploy", action="store_true") sub.add_parser("mirror") @@ -60,6 +63,16 @@ class TestArgParsing(unittest.TestCase): p_user_set_pw = user_sub.add_parser("set-password") p_user_set_pw.add_argument("target") p_user_set_pw.add_argument("password") + + # Add config subcommand for testing + p_config = sub.add_parser("config") + config_sub = p_config.add_subparsers(dest="config_action") + p_config_set = config_sub.add_parser("set") + p_config_set.add_argument("--host", default="") + p_config_set.add_argument("--infra-dir", default="") + config_sub.add_parser("get") + config_sub.add_parser("clear") + return parser.parse_args(argv) def test_up(self): @@ -189,6 +202,55 @@ class TestArgParsing(unittest.TestCase): args = self._parse(["build", "meet"]) self.assertEqual(args.what, "meet") + def test_config_set_with_host_and_infra_dir(self): + args = self._parse(["config", "set", "--host", "user@example.com", "--infra-dir", "/path/to/infra"]) + self.assertEqual(args.verb, "config") + self.assertEqual(args.config_action, "set") + self.assertEqual(args.host, "user@example.com") + self.assertEqual(args.infra_dir, "/path/to/infra") + + def test_config_set_with_only_host(self): + args = self._parse(["config", "set", "--host", "user@example.com"]) + self.assertEqual(args.verb, "config") + self.assertEqual(args.config_action, "set") + self.assertEqual(args.host, "user@example.com") + self.assertEqual(args.infra_dir, "") + + def test_config_set_with_only_infra_dir(self): + args = self._parse(["config", "set", "--infra-dir", "/path/to/infra"]) + self.assertEqual(args.verb, "config") + self.assertEqual(args.config_action, "set") + self.assertEqual(args.host, "") + self.assertEqual(args.infra_dir, "/path/to/infra") + + def test_config_get(self): + args = self._parse(["config", "get"]) + self.assertEqual(args.verb, "config") + self.assertEqual(args.config_action, "get") + + def test_config_clear(self): + args = self._parse(["config", "clear"]) + self.assertEqual(args.verb, "config") + self.assertEqual(args.config_action, "clear") + + def test_build_people(self): + args = self._parse(["build", "people"]) + self.assertEqual(args.what, "people") + self.assertFalse(args.push) + self.assertFalse(args.deploy) + + def test_build_people_push(self): + args = self._parse(["build", "people", "--push"]) + self.assertEqual(args.what, "people") + self.assertTrue(args.push) + self.assertFalse(args.deploy) + + def test_build_people_push_deploy(self): + args = self._parse(["build", "people", "--push", "--deploy"]) + self.assertEqual(args.what, "people") + self.assertTrue(args.push) + self.assertTrue(args.deploy) + def test_no_args_verb_is_none(self): args = self._parse([]) self.assertIsNone(args.verb) @@ -399,6 +461,42 @@ class TestCliDispatch(unittest.TestCase): pass mock_apply.assert_called_once_with(env="local", domain="", email="", namespace="lasuite") + def test_build_people_dispatches(self): + mock_build = MagicMock() + with patch.object(sys, "argv", ["sunbeam", "build", "people"]): + 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("people", push=False, deploy=False) + + def test_build_people_push_dispatches(self): + mock_build = MagicMock() + with patch.object(sys, "argv", ["sunbeam", "build", "people", "--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("people", push=True, deploy=False) + + def test_build_people_deploy_implies_push(self): + mock_build = MagicMock() + with patch.object(sys, "argv", ["sunbeam", "build", "people", "--push", "--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("people", push=True, deploy=True) + def test_build_meet_dispatches(self): mock_build = MagicMock() with patch.object(sys, "argv", ["sunbeam", "build", "meet"]): @@ -434,3 +532,223 @@ class TestCliDispatch(unittest.TestCase): except SystemExit: pass mock_check.assert_called_once_with("lasuite/people") + + +class TestConfigCli(unittest.TestCase): + """Test config subcommand functionality.""" + + def setUp(self): + """Set up test fixtures.""" + import tempfile + import os + self.temp_dir = tempfile.mkdtemp() + self.original_home = os.environ.get('HOME') + os.environ['HOME'] = self.temp_dir + + # Import and mock config path + from pathlib import Path + import sunbeam.config + self.original_config_path = sunbeam.config.CONFIG_PATH + sunbeam.config.CONFIG_PATH = Path(self.temp_dir) / ".sunbeam.json" + + def tearDown(self): + """Clean up test fixtures.""" + import shutil + import os + import sunbeam.config + + # Restore original config path + sunbeam.config.CONFIG_PATH = self.original_config_path + + # Clean up temp directory + shutil.rmtree(self.temp_dir) + + # Restore original HOME + if self.original_home: + os.environ['HOME'] = self.original_home + else: + del os.environ['HOME'] + + def test_config_set_and_get(self): + """Test config set and get functionality.""" + from sunbeam.config import SunbeamConfig, load_config, save_config + + # Test initial state + config = load_config() + self.assertEqual(config.production_host, "") + self.assertEqual(config.infra_directory, "") + + # Test setting config + test_config = SunbeamConfig( + production_host="user@example.com", + infra_directory="/path/to/infra" + ) + save_config(test_config) + + # Test loading config + loaded_config = load_config() + self.assertEqual(loaded_config.production_host, "user@example.com") + self.assertEqual(loaded_config.infra_directory, "/path/to/infra") + + def test_config_clear(self): + """Test config clear functionality.""" + from sunbeam.config import SunbeamConfig, load_config, save_config + from pathlib import Path + import os + + # Set a config first + test_config = SunbeamConfig( + production_host="user@example.com", + infra_directory="/path/to/infra" + ) + save_config(test_config) + + # Verify it exists + config_path = Path(self.temp_dir) / ".sunbeam.json" + self.assertTrue(config_path.exists()) + + # Clear config + os.remove(config_path) + + # Verify cleared state + cleared_config = load_config() + self.assertEqual(cleared_config.production_host, "") + self.assertEqual(cleared_config.infra_directory, "") + + def test_config_get_production_host_priority(self): + """Test that config file takes priority over environment variable.""" + from sunbeam.config import SunbeamConfig, save_config, get_production_host + import os + + # Set environment variable + os.environ['SUNBEAM_SSH_HOST'] = "env@example.com" + + # Get production host without config - should use env var + host_no_config = get_production_host() + self.assertEqual(host_no_config, "env@example.com") + + # Set config + test_config = SunbeamConfig( + production_host="config@example.com", + infra_directory="" + ) + save_config(test_config) + + # Get production host with config - should use config + host_with_config = get_production_host() + self.assertEqual(host_with_config, "config@example.com") + + # Clean up env var + del os.environ['SUNBEAM_SSH_HOST'] + + def test_config_cli_set_dispatch(self): + """Test that config set CLI dispatches correctly.""" + mock_save = MagicMock() + mock_config = MagicMock( + SunbeamConfig=MagicMock(return_value="mock_config"), + save_config=mock_save + ) + + with patch.object(sys, "argv", ["sunbeam", "config", "set", + "--host", "cli@example.com", + "--infra-dir", "/cli/infra"]): + with patch.dict("sys.modules", {"sunbeam.config": mock_config}): + import importlib, sunbeam.cli as cli_mod + importlib.reload(cli_mod) + try: + cli_mod.main() + except SystemExit: + pass + + # Verify SunbeamConfig was called with correct args + mock_config.SunbeamConfig.assert_called_once_with( + production_host="cli@example.com", + infra_directory="/cli/infra" + ) + # Verify save_config was called + mock_save.assert_called_once_with("mock_config") + + def test_config_cli_get_dispatch(self): + """Test that config get CLI dispatches correctly.""" + mock_load = MagicMock() + mock_ok = MagicMock() + mock_config = MagicMock( + load_config=mock_load, + get_production_host=MagicMock(return_value="effective@example.com") + ) + mock_output = MagicMock(ok=mock_ok) + + # Mock config with some values + mock_config_instance = MagicMock() + mock_config_instance.production_host = "loaded@example.com" + mock_config_instance.infra_directory = "/loaded/infra" + mock_load.return_value = mock_config_instance + + with patch.object(sys, "argv", ["sunbeam", "config", "get"]): + with patch.dict("sys.modules", { + "sunbeam.config": mock_config, + "sunbeam.output": mock_output + }): + import importlib, sunbeam.cli as cli_mod + importlib.reload(cli_mod) + try: + cli_mod.main() + except SystemExit: + pass + + # Verify load_config was called + mock_load.assert_called_once() + # Verify ok was called with expected messages + mock_ok.assert_any_call("Production host: loaded@example.com") + mock_ok.assert_any_call("Infrastructure directory: /loaded/infra") + mock_ok.assert_any_call("Effective production host: effective@example.com") + + def test_config_cli_clear_dispatch(self): + """Test that config clear CLI dispatches correctly.""" + mock_ok = MagicMock() + mock_warn = MagicMock() + mock_output = MagicMock(ok=mock_ok, warn=mock_warn) + mock_os = MagicMock() + mock_os.path.exists.return_value = True + + with patch.object(sys, "argv", ["sunbeam", "config", "clear"]): + with patch.dict("sys.modules", { + "sunbeam.output": mock_output, + "os": mock_os + }): + import importlib, sunbeam.cli as cli_mod + importlib.reload(cli_mod) + try: + cli_mod.main() + except SystemExit: + pass + + # Verify os.remove was called + mock_os.remove.assert_called_once() + # Verify ok was called + mock_ok.assert_called_once() + + def test_config_cli_clear_no_file(self): + """Test that config clear handles missing file gracefully.""" + mock_ok = MagicMock() + mock_warn = MagicMock() + mock_output = MagicMock(ok=mock_ok, warn=mock_warn) + mock_os = MagicMock() + mock_os.path.exists.return_value = False + + with patch.object(sys, "argv", ["sunbeam", "config", "clear"]): + with patch.dict("sys.modules", { + "sunbeam.output": mock_output, + "os": mock_os + }): + import importlib, sunbeam.cli as cli_mod + importlib.reload(cli_mod) + try: + cli_mod.main() + except SystemExit: + pass + + # Verify os.remove was not called + mock_os.remove.assert_not_called() + # Verify warn was called + mock_warn.assert_called_once_with("No configuration file found to clear")