diff --git a/tests/test_configure_watch.py b/tests/test_configure_watch.py index cf10266..e4eb352 100644 --- a/tests/test_configure_watch.py +++ b/tests/test_configure_watch.py @@ -35,7 +35,7 @@ def _invoke(runner, extra_args: list[str], side_effect=None): ): mock_exec = _executor_mock() if side_effect: - mock_exec.stream.side_effect = side_effect + mock_exec.watch_logs.side_effect = side_effect MockExecutor.return_value = mock_exec result = runner.invoke( app, @@ -48,18 +48,19 @@ def test_watch_streams_journalctl_after_configure(runner): result, mock_exec = _invoke(runner, ["--watch"]) assert result.exit_code == 0 - mock_exec.stream.assert_called_once_with("journalctl --user -u service-myapp-production -f") + mock_exec.watch_logs.assert_called_once_with("service", "service-myapp-production") def test_no_watch_does_not_stream(runner): result, mock_exec = _invoke(runner, []) assert result.exit_code == 0 - mock_exec.stream.assert_not_called() + mock_exec.watch_logs.assert_not_called() def test_watch_handles_keyboard_interrupt(runner): - result, mock_exec = _invoke(runner, ["--watch"], side_effect=KeyboardInterrupt) + # KeyboardInterrupt is handled inside Executor.watch_logs; command exits cleanly + result, mock_exec = _invoke(runner, ["--watch"]) assert result.exit_code == 0 - mock_exec.stream.assert_called_once() + mock_exec.watch_logs.assert_called_once() diff --git a/tests/test_executor_watch_logs.py b/tests/test_executor_watch_logs.py new file mode 100644 index 0000000..b88bc0a --- /dev/null +++ b/tests/test_executor_watch_logs.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +from trobz_deploy.utils.executor import Executor, ExecutorError + + +def _executor() -> Any: + executor = Executor(None) + executor.capture = MagicMock() # type: ignore[method-assign] + executor.run = MagicMock() # type: ignore[method-assign] + executor.stream = MagicMock() # type: ignore[method-assign] + return executor + + +def _streamed_command(executor: Any) -> str: + return executor.stream.call_args[0][0] + + +def test_non_odoo_type_streams_journalctl_only_in_its_color(): + executor = _executor() + + executor.watch_logs("python", "service-myapp-production") + + cmd = _streamed_command(executor) + assert cmd == ( + 'journalctl --user -u service-myapp-production -f -o short-iso | stdbuf -oL sed "s/.*/\x1b[36m&\x1b[0m/"' + ) + executor.capture.assert_not_called() + executor.run.assert_not_called() + + +def test_odoo_type_merges_each_log_in_its_own_color(): + executor = _executor() + executor.capture.side_effect = [ + "/home/deploy", # echo $HOME + "/home/deploy/odoo-foo-staging/log/odoo.log", # grep logfile from odoo.conf + ] + executor.run.side_effect = None # `test -f log/upgrade.log` succeeds + + executor.watch_logs("odoo", "odoo-foo-staging") + + cmd = _streamed_command(executor) + assert cmd == ( + "( journalctl --user -u odoo-foo-staging -f -o short-iso" + ' | stdbuf -oL sed "s/.*/\x1b[36m&\x1b[0m/"' + " & tail -f /home/deploy/odoo-foo-staging/log/odoo.log" + ' | stdbuf -oL sed "s/.*/\x1b[32m&\x1b[0m/"' + " & tail -f /home/deploy/odoo-foo-staging/log/upgrade.log" + ' | stdbuf -oL sed "s/.*/\x1b[33m&\x1b[0m/"' + " & wait )" + ) + + +def test_odoo_type_without_log_files_streams_journalctl_only(): + executor = _executor() + executor.capture.side_effect = [ + "/home/deploy", # echo $HOME + "", # no logfile configured + ] + executor.run.side_effect = ExecutorError("not found") # `test -f log/upgrade.log` fails + + executor.watch_logs("odoo", "odoo-foo-staging") + + cmd = _streamed_command(executor) + assert cmd == ('journalctl --user -u odoo-foo-staging -f -o short-iso | stdbuf -oL sed "s/.*/\x1b[36m&\x1b[0m/"') + + +def test_keyboard_interrupt_is_swallowed(): + executor = _executor() + executor.capture.side_effect = ExecutorError("unreachable") + executor.stream.side_effect = KeyboardInterrupt + + with patch("trobz_deploy.utils.executor.typer.echo") as mock_echo: + executor.watch_logs("python", "service-myapp-production") + + mock_echo.assert_called_once_with() diff --git a/tests/test_restart_watch.py b/tests/test_restart_watch.py index 366ca4b..4e25b30 100644 --- a/tests/test_restart_watch.py +++ b/tests/test_restart_watch.py @@ -25,7 +25,7 @@ def _invoke(runner, extra_args: list[str], side_effect=None): ): mock_exec = _executor_mock() if side_effect: - mock_exec.stream.side_effect = side_effect + mock_exec.watch_logs.side_effect = side_effect MockExecutor.return_value = mock_exec result = runner.invoke( app, @@ -45,18 +45,19 @@ def test_watch_streams_journalctl_after_restart(runner): result, mock_exec = _invoke(runner, ["--watch"]) assert result.exit_code == 0 - mock_exec.stream.assert_called_once_with("journalctl --user -u service-myapp-production -f") + mock_exec.watch_logs.assert_called_once_with("python", "service-myapp-production") def test_no_watch_does_not_stream(runner): result, mock_exec = _invoke(runner, []) assert result.exit_code == 0 - mock_exec.stream.assert_not_called() + mock_exec.watch_logs.assert_not_called() def test_watch_handles_keyboard_interrupt(runner): - result, mock_exec = _invoke(runner, ["--watch"], side_effect=KeyboardInterrupt) + # KeyboardInterrupt is handled inside Executor.watch_logs; command exits cleanly + result, mock_exec = _invoke(runner, ["--watch"]) assert result.exit_code == 0 - mock_exec.stream.assert_called_once() + mock_exec.watch_logs.assert_called_once() diff --git a/tests/test_status_watch.py b/tests/test_status_watch.py index f932a43..b807dde 100644 --- a/tests/test_status_watch.py +++ b/tests/test_status_watch.py @@ -31,7 +31,7 @@ def _invoke(runner, extra_args: list[str], side_effect=None): ): mock_exec = _executor_mock() if side_effect: - mock_exec.stream.side_effect = side_effect + mock_exec.watch_logs.side_effect = side_effect MockExecutor.return_value = mock_exec result = runner.invoke( app, @@ -44,18 +44,19 @@ def test_watch_streams_journalctl_after_status(runner): result, mock_exec = _invoke(runner, ["--watch"]) assert result.exit_code == 0 - mock_exec.stream.assert_called_once_with("journalctl --user -u service-myapp-production -f") + mock_exec.watch_logs.assert_called_once_with("python", "service-myapp-production") def test_no_watch_does_not_stream(runner): result, mock_exec = _invoke(runner, []) assert result.exit_code == 0 - mock_exec.stream.assert_not_called() + mock_exec.watch_logs.assert_not_called() def test_watch_handles_keyboard_interrupt(runner): - result, mock_exec = _invoke(runner, ["--watch"], side_effect=KeyboardInterrupt) + # KeyboardInterrupt is handled inside Executor.watch_logs; command exits cleanly + result, mock_exec = _invoke(runner, ["--watch"]) assert result.exit_code == 0 - mock_exec.stream.assert_called_once() + mock_exec.watch_logs.assert_called_once() diff --git a/tests/test_update_watch.py b/tests/test_update_watch.py index f04c053..38e3e58 100644 --- a/tests/test_update_watch.py +++ b/tests/test_update_watch.py @@ -26,7 +26,7 @@ def _invoke(runner, extra_args: list[str], side_effect=None): ): mock_exec = _executor_mock() if side_effect: - mock_exec.stream.side_effect = side_effect + mock_exec.watch_logs.side_effect = side_effect MockExecutor.return_value = mock_exec result = runner.invoke( app, @@ -39,18 +39,19 @@ def test_watch_streams_journalctl_after_success(runner): result, mock_exec = _invoke(runner, ["--watch"]) assert result.exit_code == 0 - mock_exec.stream.assert_called_once_with("journalctl --user -u service-myapp-production -f") + mock_exec.watch_logs.assert_called_once_with("service", "service-myapp-production") def test_no_watch_does_not_stream(runner): result, mock_exec = _invoke(runner, []) assert result.exit_code == 0 - mock_exec.stream.assert_not_called() + mock_exec.watch_logs.assert_not_called() def test_watch_handles_keyboard_interrupt(runner): - result, mock_exec = _invoke(runner, ["--watch"], side_effect=KeyboardInterrupt) + # KeyboardInterrupt is handled inside Executor.watch_logs; command exits cleanly + result, mock_exec = _invoke(runner, ["--watch"]) assert result.exit_code == 0 - mock_exec.stream.assert_called_once() + mock_exec.watch_logs.assert_called_once() diff --git a/trobz_deploy/command/configure.py b/trobz_deploy/command/configure.py index c1ba679..dbd780e 100644 --- a/trobz_deploy/command/configure.py +++ b/trobz_deploy/command/configure.py @@ -1,22 +1,15 @@ from __future__ import annotations -from enum import Enum from typing import Annotated, Any import typer -from trobz_deploy.utils.config import load_config, resolve_options +from trobz_deploy.utils.config import DeployType, load_config, resolve_options from trobz_deploy.utils.executor import Executor, ExecutorError from trobz_deploy.utils.render import render_unit from trobz_deploy.utils.venv import setup_odoo_venv, setup_package_venv, setup_python_venv -class DeployType(str, Enum): - odoo = "odoo" - python = "python" - service = "service" - - def _is_git_repo(executor: Executor, path: str) -> bool: try: executor.run(f"test -d {path}/.git") @@ -212,8 +205,4 @@ def configure( # noqa: C901 typer.secho(f"\nInstance {instance_name!r} configured successfully.", fg="green") if watch: - typer.secho("\nWatching service logs (Ctrl+C to stop)…", fg="cyan") - try: - executor.stream(f"journalctl --user -u {instance_name} -f") - except KeyboardInterrupt: - typer.echo() + executor.watch_logs(eff_type, instance_name) diff --git a/trobz_deploy/command/restart.py b/trobz_deploy/command/restart.py index a86f164..685d9e9 100644 --- a/trobz_deploy/command/restart.py +++ b/trobz_deploy/command/restart.py @@ -4,7 +4,7 @@ import typer -from trobz_deploy.utils.config import load_config +from trobz_deploy.utils.config import DeployType, load_config, resolve_options from trobz_deploy.utils.executor import Executor, ExecutorError @@ -12,6 +12,10 @@ def restart( ctx: typer.Context, instance_name: Annotated[str, typer.Argument()], ssh_host: Annotated[str | None, typer.Argument()] = None, + deploy_type: Annotated[ + DeployType | None, + typer.Option("--type", help="Deployment type (auto-detected from instance name prefix if omitted)."), + ] = None, ssh_port: Annotated[int | None, typer.Option("-p", "--port", help="SSH port on the remote host.")] = None, watch: Annotated[ bool, @@ -20,9 +24,21 @@ def restart( ) -> None: """Restart the systemd unit for a deployment instance.""" cfg = load_config(ctx.obj["config"], instance_name) + try: + opts = resolve_options( + cfg, + instance_name, + ssh_host=ssh_host, + ssh_port=ssh_port, + deploy_type=deploy_type.value if deploy_type else None, + ) + except ValueError as exc: + typer.echo(typer.style(str(exc), fg="red"), err=True) + raise typer.Exit(code=1) from exc - eff_ssh_host: str | None = ssh_host if ssh_host is not None else cfg.get("ssh_host") - eff_ssh_port: int | None = ssh_port if ssh_port is not None else cfg.get("ssh_port") + eff_ssh_host: str | None = opts.get("ssh_host") + eff_ssh_port: int | None = opts.get("ssh_port") + eff_type: str = opts["type"] executor = Executor(eff_ssh_host, ctx.obj["verbose"], ssh_port=eff_ssh_port) @@ -36,8 +52,4 @@ def restart( typer.secho(f"\nInstance {instance_name!r} restarted.", fg="green") if watch: - typer.secho("\nWatching service logs (Ctrl+C to stop)…", fg="cyan") - try: - executor.stream(f"journalctl --user -u {instance_name} -f") - except KeyboardInterrupt: - typer.echo() + executor.watch_logs(eff_type, instance_name) diff --git a/trobz_deploy/command/status.py b/trobz_deploy/command/status.py index c2962a0..8b96014 100644 --- a/trobz_deploy/command/status.py +++ b/trobz_deploy/command/status.py @@ -4,7 +4,7 @@ import typer -from trobz_deploy.utils.config import load_config +from trobz_deploy.utils.config import DeployType, load_config, resolve_options from trobz_deploy.utils.executor import Executor, ExecutorError @@ -42,6 +42,10 @@ def status( ctx: typer.Context, instance_name: Annotated[str, typer.Argument()], ssh_host: Annotated[str | None, typer.Argument()] = None, + deploy_type: Annotated[ + DeployType | None, + typer.Option("--type", help="Deployment type (auto-detected from instance name prefix if omitted)."), + ] = None, ssh_port: Annotated[int | None, typer.Option("-p", "--port", help="SSH port on the remote host.")] = None, watch: Annotated[ bool, @@ -50,13 +54,25 @@ def status( ) -> None: """Show status of a deployment instance.""" cfg = load_config(ctx.obj["config"], instance_name) + try: + opts = resolve_options( + cfg, + instance_name, + ssh_host=ssh_host, + ssh_port=ssh_port, + deploy_type=deploy_type.value if deploy_type else None, + ) + except ValueError as exc: + typer.echo(typer.style(str(exc), fg="red"), err=True) + raise typer.Exit(code=1) from exc - # Resolve ssh_host/ssh_port: CLI arg > config value - eff_ssh_host: str | None = ssh_host if ssh_host is not None else cfg.get("ssh_host") - eff_ssh_port: int | None = ssh_port if ssh_port is not None else cfg.get("ssh_port") + eff_ssh_host: str | None = opts.get("ssh_host") + eff_ssh_port: int | None = opts.get("ssh_port") + eff_type: str = opts["type"] executor = Executor(eff_ssh_host, ctx.obj["verbose"], ssh_port=eff_ssh_port) - instance_path = f"$HOME/{instance_name}" + home_dir = executor.capture("echo $HOME") + instance_path = f"{home_dir}/{instance_name}" # Step 2: Verify instance directory exists try: @@ -87,8 +103,4 @@ def status( typer.echo(f"Unit: {unit_line}") if watch: - typer.secho("\nWatching service logs (Ctrl+C to stop)…", fg="cyan") - try: - executor.stream(f"journalctl --user -u {instance_name} -f") - except KeyboardInterrupt: - typer.echo() + executor.watch_logs(eff_type, instance_name) diff --git a/trobz_deploy/command/update.py b/trobz_deploy/command/update.py index c61f907..167e716 100644 --- a/trobz_deploy/command/update.py +++ b/trobz_deploy/command/update.py @@ -1,22 +1,15 @@ from __future__ import annotations -from enum import Enum from typing import Annotated import typer from trobz_deploy.utils.addons import get_addons_path -from trobz_deploy.utils.config import load_config, resolve_options +from trobz_deploy.utils.config import DeployType, load_config, resolve_options from trobz_deploy.utils.executor import Executor, ExecutorError from trobz_deploy.utils.venv import setup_python_deps, upgrade_package -class DeployType(str, Enum): - odoo = "odoo" - python = "python" - service = "service" - - def update( # noqa: C901 ctx: typer.Context, instance_name: Annotated[str, typer.Argument()], @@ -176,7 +169,8 @@ def run_hooks(hook_name: str) -> bool: for db in eff_db: typer.secho(f"\nUpdating database {db!r}…", fg="green") executor.run( - f".venv/bin/click-odoo-update --config config/odoo.conf -d {db} --addons-path={addons_path}", + f".venv/bin/click-odoo-update --config config/odoo.conf -d {db}" + f" --addons-path={addons_path} --logfile log/upgrade.log", cwd=instance_path, ) executor.run(f"systemctl --user restart {instance_name}") @@ -193,8 +187,4 @@ def run_hooks(hook_name: str) -> bool: typer.secho(f"\nInstance {instance_name!r} updated successfully.", fg="green") if watch: - typer.secho("\nWatching service logs (Ctrl+C to stop)…", fg="cyan") - try: - executor.stream(f"journalctl --user -u {instance_name} -f") - except KeyboardInterrupt: - typer.echo() + executor.watch_logs(eff_type, instance_name) diff --git a/trobz_deploy/utils/config.py b/trobz_deploy/utils/config.py index 531bb45..5fb6a85 100644 --- a/trobz_deploy/utils/config.py +++ b/trobz_deploy/utils/config.py @@ -1,5 +1,6 @@ from __future__ import annotations +from enum import Enum from pathlib import Path from typing import Any @@ -7,6 +8,13 @@ KNOWN_ENVS: frozenset[str] = frozenset({"integration", "staging", "production", "hotfix", "debug", "demo"}) + +class DeployType(str, Enum): + odoo = "odoo" + python = "python" + service = "service" + + # Maps instance name prefix → deployment type _PREFIX_TO_TYPE: dict[str, str] = { "odoo": "odoo", diff --git a/trobz_deploy/utils/executor.py b/trobz_deploy/utils/executor.py index 6bc7a0a..027b900 100644 --- a/trobz_deploy/utils/executor.py +++ b/trobz_deploy/utils/executor.py @@ -108,6 +108,62 @@ def capture(self, command: str, cwd: str | None = None) -> str: return result.stdout.strip() + @staticmethod + def _colorize(command: str, ansi_code: str) -> str: + """Pipe *command*'s output through ``sed``, wrapping each line in an ANSI color.""" + return f'{command} | stdbuf -oL sed "s/.*/\x1b[{ansi_code}m&\x1b[0m/"' + + def watch_logs(self, eff_type: str, instance_name: str) -> None: + """Stream journalctl logs for *instance_name*, merged with Odoo log files if found. + + For ``eff_type == "odoo"``, reads ``config/odoo.conf`` under the instance + home directory to locate ``logfile``, and checks for a ``log/upgrade.log`` + written by ``click-odoo-update``. Any streams found are tailed concurrently. + """ + typer.secho("\nWatching service logs (Ctrl+C to stop)…", fg="cyan") + + log_file: str | None = None + upgrade_log_file: str | None = None + if eff_type == "odoo": + try: + home_dir = self.capture("echo $HOME") + instance_path = f"{home_dir}/{instance_name}" + except ExecutorError: + instance_path = None + + if instance_path: + try: + conf = f"{instance_path}/config/odoo.conf" + raw = self.capture(f"grep -E '^logfile' {conf} | cut -d= -f2 | tr -d ' ' || true") + candidate = (raw or "").strip() + if candidate and candidate.lower() not in ("false", "none"): + log_file = candidate + except ExecutorError: + pass + + candidate_upgrade_log = f"{instance_path}/log/upgrade.log" + try: + self.run(f"test -f {candidate_upgrade_log}") + upgrade_log_file = candidate_upgrade_log + except ExecutorError: + pass + + streams: list[str] = [ + self._colorize(f"journalctl --user -u {instance_name} -f -o short-iso", "36") # cyan + ] + if log_file: + typer.secho(f"Merging with Odoo log: {log_file}", fg="cyan") + streams.append(self._colorize(f"tail -f {log_file}", "32")) # green + if upgrade_log_file: + typer.secho(f"Merging with upgrade log: {upgrade_log_file}", fg="cyan") + streams.append(self._colorize(f"tail -f {upgrade_log_file}", "33")) # yellow + + try: + cmd = f"( {' & '.join(streams)} & wait )" if len(streams) > 1 else streams[0] + self.stream(cmd) + except KeyboardInterrupt: + typer.echo() + def stream(self, command: str, cwd: str | None = None) -> None: """Run a long-lived streaming command (e.g. journalctl -f).