From eb318b5ed0409a8b2bbe9e314fe30a4cd85901f7 Mon Sep 17 00:00:00 2001 From: Hai Lang Date: Wed, 17 Jun 2026 18:15:09 +0700 Subject: [PATCH 1/2] refactor(configure,update,status): move code from main function to helper Forge ID: F#T67958 --- trobz_deploy/command/configure.py | 194 +++++++++++---------- trobz_deploy/command/status.py | 81 +++++---- trobz_deploy/command/update.py | 278 ++++++++++++++++-------------- 3 files changed, 299 insertions(+), 254 deletions(-) diff --git a/trobz_deploy/command/configure.py b/trobz_deploy/command/configure.py index 9350a6a..53f8c10 100644 --- a/trobz_deploy/command/configure.py +++ b/trobz_deploy/command/configure.py @@ -60,91 +60,18 @@ def _ensure_postgres_user(executor: Executor, instance_name: str, dry_run: bool } -def configure( # noqa: C901 - ctx: typer.Context, - instance_name: Annotated[str, typer.Argument()], - ssh_host: Annotated[str | None, typer.Argument()] = None, - repo_url: 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, - repo_subdir: Annotated[ - str | None, - typer.Option(help="Subdirectory within the repo to use as the service root (for monorepos)."), - ] = None, - repo_branch: Annotated[ - str | None, - typer.Option(help="Git branch to clone and track (defaults to the repository's default branch)."), - ] = None, - recreate: Annotated[ - bool, - typer.Option( - help="Re-create venv in case it exists. This is useful when you want to update the venv.", - ), - ] = False, - watch: Annotated[ - bool, - typer.Option( - "--watch", - help=( - "Stream service logs with journalctl after a successful configure. " - "Also merge with odoo and click-odoo-update logs if applicable." - ), - ), - ] = False, - steps: Annotated[ - str, - typer.Option( - "--steps", - help=f"Comma-separated steps to run, or 'all'. Available: {', '.join(CONFIGURE_STEPS.keys())}.", - ), - ] = "all", - skip_steps: Annotated[ - str | None, - typer.Option( - "--except", - help=f"Comma-separated steps to skip. Available: {', '.join(CONFIGURE_STEPS.keys())}.", - ), - ] = None, - dry_run: Annotated[ - bool, - typer.Option( - "--dry-run", - help="Go through all steps without running any writing/destructive commands.", - ), - ] = False, +def _perform_configure( # noqa: C901 + opts: dict[str, Any], + instance_name: str, + eff_steps: list[str], + eff_skip_steps: list[str], + watch: bool, + dry_run: bool, ) -> None: - """Configure a new deployment instance.""" - eff_steps = parse_step_option(steps) - eff_skip_steps = parse_step_option(skip_steps) - try: - validate_step_slugs("--steps", eff_steps, CONFIGURE_STEPS, allow_all=True) - validate_step_slugs("--except", eff_skip_steps, CONFIGURE_STEPS, allow_all=False) - except ValueError as exc: - typer.echo(typer.style(str(exc), fg="red"), err=True) - raise typer.Exit(code=1) from exc - def _run_step(slug: str) -> bool: return ("all" in eff_steps or slug in eff_steps) and slug not in eff_skip_steps - cfg = load_config(ctx.obj["config"], instance_name) - try: - opts = resolve_options( - cfg, - instance_name, - ssh_host=ssh_host, - ssh_port=ssh_port, - repo_url=repo_url, - repo_branch=repo_branch, - deploy_type=deploy_type.value if deploy_type else None, - repo_subdir=repo_subdir, - ) - except ValueError as exc: - typer.echo(typer.style(str(exc), fg="red"), err=True) - raise typer.Exit(code=1) from exc - + recreate = opts["recreate"] eff_ssh_host: str | None = opts.get("ssh_host") eff_ssh_port: int | None = opts.get("ssh_port") eff_repo_url: str | None = opts.get("repo_url") @@ -158,10 +85,7 @@ def _run_step(slug: str) -> bool: typer.echo(typer.style(msg, fg="red"), err=True) raise typer.Exit(code=1) - if dry_run: - typer.secho("\nDry run: no writing/destructive commands will be executed.", fg="cyan") - - executor = Executor(eff_ssh_host, ctx.obj["verbose"], ssh_port=eff_ssh_port) + executor = Executor(eff_ssh_host, opts["verbose"], ssh_port=eff_ssh_port) home_dir = executor.capture("echo $HOME") instance_path = f"{home_dir}/{instance_name}" eff_repo_subdir: str | None = opts.get("repo_subdir") @@ -306,13 +230,103 @@ def _run_step(slug: str) -> bool: fg="yellow", ) - if dry_run: - typer.secho(f"\nDry run complete: instance {instance_name!r} was not changed.", fg="green") - else: - typer.secho(f"\nInstance {instance_name!r} configured successfully.", fg="green") - if watch: if dry_run: typer.secho("Skipping --watch: no service was started in dry-run mode.", fg="yellow") else: executor.watch_logs(eff_type, instance_name) + + +def configure( + ctx: typer.Context, + instance_name: Annotated[str, typer.Argument()], + ssh_host: Annotated[str | None, typer.Argument()] = None, + repo_url: 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, + repo_subdir: Annotated[ + str | None, + typer.Option(help="Subdirectory within the repo to use as the service root (for monorepos)."), + ] = None, + repo_branch: Annotated[ + str | None, + typer.Option(help="Git branch to clone and track (defaults to the repository's default branch)."), + ] = None, + recreate: Annotated[ + bool, + typer.Option( + help="Re-create venv in case it exists. This is useful when you want to update the venv.", + ), + ] = False, + watch: Annotated[ + bool, + typer.Option( + "--watch", + help=( + "Stream service logs with journalctl after a successful configure. " + "Also merge with odoo and click-odoo-update logs if applicable." + ), + ), + ] = False, + steps: Annotated[ + str, + typer.Option( + "--steps", + help=f"Comma-separated steps to run, or 'all'. Available: {', '.join(CONFIGURE_STEPS.keys())}.", + ), + ] = "all", + skip_steps: Annotated[ + str | None, + typer.Option( + "--except", + help=f"Comma-separated steps to skip. Available: {', '.join(CONFIGURE_STEPS.keys())}.", + ), + ] = None, + dry_run: Annotated[ + bool, + typer.Option( + "--dry-run", + help="Go through all steps without running any writing/destructive commands.", + ), + ] = False, +) -> None: + """Configure a new deployment instance.""" + eff_steps = parse_step_option(steps) + eff_skip_steps = parse_step_option(skip_steps) + try: + validate_step_slugs("--steps", eff_steps, CONFIGURE_STEPS, allow_all=True) + validate_step_slugs("--except", eff_skip_steps, CONFIGURE_STEPS, allow_all=False) + except ValueError as exc: + typer.echo(typer.style(str(exc), fg="red"), err=True) + raise typer.Exit(code=1) from exc + + cfg = load_config(ctx.obj["config"], instance_name) + try: + opts = resolve_options( + cfg, + instance_name, + ssh_host=ssh_host, + ssh_port=ssh_port, + repo_url=repo_url, + repo_branch=repo_branch, + deploy_type=deploy_type.value if deploy_type else None, + repo_subdir=repo_subdir, + ) + except ValueError as exc: + typer.echo(typer.style(str(exc), fg="red"), err=True) + raise typer.Exit(code=1) from exc + opts["verbose"] = ctx.obj["verbose"] + opts["recreate"] = recreate + + if dry_run: + typer.secho("\nDry run: no writing/destructive commands will be executed.", fg="cyan") + + _perform_configure(opts, instance_name, eff_steps, eff_skip_steps, watch, dry_run) + + if dry_run: + typer.secho(f"\nDry run complete: instance {instance_name!r} was not changed.", fg="green") + else: + typer.secho(f"\nInstance {instance_name!r} configured successfully.", fg="green") diff --git a/trobz_deploy/command/status.py b/trobz_deploy/command/status.py index 4bc4764..35db69f 100644 --- a/trobz_deploy/command/status.py +++ b/trobz_deploy/command/status.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Annotated +from typing import Annotated, Any import typer @@ -38,45 +38,16 @@ def _get_git_info(executor: Executor, instance_path: str) -> tuple[str, str, str return remote_url, branch, commit -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, - typer.Option( - "--watch", - help=( - "Stream service logs with journalctl after showing status. " - "Also merge with odoo and click-odoo-update logs if applicable." - ), - ), - ] = False, +def _perform_status( + opts: dict[str, Any], + instance_name: str, + watch: bool, ) -> 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 - 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) + executor = Executor(eff_ssh_host, opts["verbose"], ssh_port=eff_ssh_port) home_dir = executor.capture("echo $HOME") instance_path = f"{home_dir}/{instance_name}" @@ -89,7 +60,7 @@ def status( raise typer.Exit(code=1) from None # Step 3: Git info (skipped for no-repo service instances) - has_repo = bool(cfg.get("repo_url")) + has_repo = bool(opts.get("repo_url")) remote_url = branch = commit = None if has_repo: try: @@ -110,3 +81,41 @@ def status( if watch: executor.watch_logs(eff_type, instance_name) + + +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, + typer.Option( + "--watch", + help=( + "Stream service logs with journalctl after showing status. " + "Also merge with odoo and click-odoo-update logs if applicable." + ), + ), + ] = False, +) -> 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 + opts["verbose"] = ctx.obj["verbose"] + + _perform_status(opts, instance_name, watch) diff --git a/trobz_deploy/command/update.py b/trobz_deploy/command/update.py index 237eb0e..e3b96b1 100644 --- a/trobz_deploy/command/update.py +++ b/trobz_deploy/command/update.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Annotated +from typing import Annotated, Any import typer @@ -16,128 +16,22 @@ } -def update( # noqa: C901 - 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, - db: Annotated[ - str | None, - typer.Option( - help="Override the target database name (Odoo only). Can be comma-separated for multiple databases." - ), - ] = None, - ssh_port: Annotated[int | None, typer.Option("-p", "--port", help="SSH port on the remote host.")] = None, - ignore_hooks: Annotated[bool, typer.Option("--ignore-hooks", help="Skip all hook execution.")] = False, - repo_subdir: Annotated[ - str | None, - typer.Option(help="Subdirectory within the repo to use as the service root (for monorepos)."), - ] = None, - repo_branch: Annotated[ - str | None, - typer.Option(help="Git branch to pull (defaults to the currently checked-out branch)."), - ] = None, - watch: Annotated[ - bool, - typer.Option( - "--watch", - help=( - "Stream service logs with journalctl after a successful update. " - "Also merge with odoo and click-odoo-update logs if applicable." - ), - ), - ] = False, - steps: Annotated[ - str, - typer.Option( - "--steps", - help=f"Comma-separated steps to run, or 'all'. Available: {', '.join(UPDATE_STEPS.keys())}.", - ), - ] = "all", - skip_steps: Annotated[ - str | None, - typer.Option( - "--except", - help=f"Comma-separated steps to skip. Available: {', '.join(UPDATE_STEPS.keys())}.", - ), - ] = None, - ignore_addons: Annotated[ - str | None, - typer.Option( - help=( - "Comma-separated list of addons to ignore. These will not be updated if their checksum has changed. " - "Use with care. Passed to click-odoo-update --ignore-addons." - ), - ), - ] = None, - ignore_core_addons: Annotated[ - bool, - typer.Option( - "--ignore-core-addons", - help=( - "Passed to click-odoo-update --ignore-core-addons. If this option is set, Odoo CE and EE addons " - "are not updated. This is normally safe, due the Odoo stable policy." - ), - ), - ] = False, - update_all: Annotated[ - bool, - typer.Option( - "--update-all", - help="Passed to click-odoo-update --update-all. Force a complete upgrade (-u base).", - ), - ] = False, - modules: Annotated[ - str | None, - typer.Option( - "-m", - "--modules", - help=( - "Comma-separated list of modules to update by running Odoo directly " - "(-u MODULES --stop-after-init), skipping click-odoo-update." - ), - ), - ] = None, - dry_run: Annotated[ - bool, - typer.Option( - "--dry-run", - help="Go through all steps without running any writing/destructive commands.", - ), - ] = False, +def _perform_update( # noqa: C901 + opts: dict[str, Any], + instance_name: str, + eff_steps: list[str], + eff_skip_steps: list[str], + watch: bool, + dry_run: bool, ) -> None: - """Update an existing deployment instance.""" - eff_steps = parse_step_option(steps) - eff_skip_steps = parse_step_option(skip_steps) - try: - validate_step_slugs("--steps", eff_steps, UPDATE_STEPS, allow_all=True) - validate_step_slugs("--except", eff_skip_steps, UPDATE_STEPS, allow_all=False) - except ValueError as exc: - typer.echo(typer.style(str(exc), fg="red"), err=True) - raise typer.Exit(code=1) from exc - def _run_step(slug: str) -> bool: return ("all" in eff_steps or slug in eff_steps) and slug not in eff_skip_steps - 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, - db=db, - repo_subdir=repo_subdir, - repo_branch=repo_branch, - ) - except ValueError as exc: - typer.echo(typer.style(str(exc), fg="red"), err=True) - raise typer.Exit(code=1) from exc - + ignore_hooks = opts["ignore_hooks"] + ignore_addons = opts["ignore_addons"] + ignore_core_addons = opts["ignore_core_addons"] + update_all = opts["update_all"] + modules = opts["modules"] eff_ssh_host: str | None = opts.get("ssh_host") eff_ssh_port: int | None = opts.get("ssh_port") eff_type: str = opts["type"] @@ -149,10 +43,7 @@ def _run_step(slug: str) -> bool: eff_requirements: list[str] = ([_req] if isinstance(_req, str) else _req) if _req else [] hooks: dict = opts.get("hooks", {}) - if dry_run: - typer.secho("\nDry run: no writing/destructive commands will be executed.", fg="cyan") - - executor = Executor(eff_ssh_host, ctx.obj["verbose"], ssh_port=eff_ssh_port) + executor = Executor(eff_ssh_host, opts["verbose"], ssh_port=eff_ssh_port) home_dir = executor.capture("echo $HOME") instance_path = f"{home_dir}/{instance_name}" eff_repo_subdir: str | None = opts.get("repo_subdir") @@ -304,13 +195,144 @@ def run_hooks(hook_name: str) -> bool: run_hooks("post-update") run_hooks("post-update-success") - if dry_run: - typer.secho(f"\nDry run complete: instance {instance_name!r} was not updated.", fg="green") - else: - typer.secho(f"\nInstance {instance_name!r} updated successfully.", fg="green") - if watch: if dry_run: typer.secho("Skipping --watch: no service was restarted in dry-run mode.", fg="yellow") else: executor.watch_logs(eff_type, instance_name) + + +def update( + 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, + db: Annotated[ + str | None, + typer.Option( + help="Override the target database name (Odoo only). Can be comma-separated for multiple databases." + ), + ] = None, + ssh_port: Annotated[int | None, typer.Option("-p", "--port", help="SSH port on the remote host.")] = None, + ignore_hooks: Annotated[bool, typer.Option("--ignore-hooks", help="Skip all hook execution.")] = False, + repo_subdir: Annotated[ + str | None, + typer.Option(help="Subdirectory within the repo to use as the service root (for monorepos)."), + ] = None, + repo_branch: Annotated[ + str | None, + typer.Option(help="Git branch to pull (defaults to the currently checked-out branch)."), + ] = None, + watch: Annotated[ + bool, + typer.Option( + "--watch", + help=( + "Stream service logs with journalctl after a successful update. " + "Also merge with odoo and click-odoo-update logs if applicable." + ), + ), + ] = False, + steps: Annotated[ + str, + typer.Option( + "--steps", + help=f"Comma-separated steps to run, or 'all'. Available: {', '.join(UPDATE_STEPS.keys())}.", + ), + ] = "all", + skip_steps: Annotated[ + str | None, + typer.Option( + "--except", + help=f"Comma-separated steps to skip. Available: {', '.join(UPDATE_STEPS.keys())}.", + ), + ] = None, + ignore_addons: Annotated[ + str | None, + typer.Option( + help=( + "Comma-separated list of addons to ignore. These will not be updated if their checksum has changed. " + "Use with care. Passed to click-odoo-update --ignore-addons." + ), + ), + ] = None, + ignore_core_addons: Annotated[ + bool, + typer.Option( + "--ignore-core-addons", + help=( + "Passed to click-odoo-update --ignore-core-addons. If this option is set, Odoo CE and EE addons " + "are not updated. This is normally safe, due the Odoo stable policy." + ), + ), + ] = False, + update_all: Annotated[ + bool, + typer.Option( + "--update-all", + help="Passed to click-odoo-update --update-all. Force a complete upgrade (-u base).", + ), + ] = False, + modules: Annotated[ + str | None, + typer.Option( + "-m", + "--modules", + help=( + "Comma-separated list of modules to update by running Odoo directly " + "(-u MODULES --stop-after-init), skipping click-odoo-update." + ), + ), + ] = None, + dry_run: Annotated[ + bool, + typer.Option( + "--dry-run", + help="Go through all steps without running any writing/destructive commands.", + ), + ] = False, +) -> None: + """Update an existing deployment instance.""" + eff_steps = parse_step_option(steps) + eff_skip_steps = parse_step_option(skip_steps) + try: + validate_step_slugs("--steps", eff_steps, UPDATE_STEPS, allow_all=True) + validate_step_slugs("--except", eff_skip_steps, UPDATE_STEPS, allow_all=False) + except ValueError as exc: + typer.echo(typer.style(str(exc), fg="red"), err=True) + raise typer.Exit(code=1) from exc + + 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, + db=db, + repo_subdir=repo_subdir, + repo_branch=repo_branch, + ) + except ValueError as exc: + typer.echo(typer.style(str(exc), fg="red"), err=True) + raise typer.Exit(code=1) from exc + opts["verbose"] = ctx.obj["verbose"] + opts["ignore_hooks"] = ignore_hooks + opts["ignore_addons"] = ignore_addons + opts["ignore_core_addons"] = ignore_core_addons + opts["update_all"] = update_all + opts["modules"] = modules + + if dry_run: + typer.secho("\nDry run: no writing/destructive commands will be executed.", fg="cyan") + + _perform_update(opts, instance_name, eff_steps, eff_skip_steps, watch, dry_run) + + if dry_run: + typer.secho(f"\nDry run complete: instance {instance_name!r} was not updated.", fg="green") + else: + typer.secho(f"\nInstance {instance_name!r} updated successfully.", fg="green") From eccbd7f687db6dae204683303689b8c617479d75 Mon Sep 17 00:00:00 2001 From: Hai Lang Date: Fri, 19 Jun 2026 14:48:00 +0700 Subject: [PATCH 2/2] feat(multi-host): support a list of hosts in ssh_host for configure/update/status/restart Lets a single instance roll configure/update/status/restart out across several hosts sequentially, with per-host steps/watch overrides and a shared ssh_user. Hosts are visited in order and the run aborts on the first failing host. When more than one host ends up watched, their logs are merged with a colored [hostname] prefix instead of blocking one at a time. Co-Authored-By: Claude Sonnet 4.6 Forge ID: F#T67958 --- SPEC.md | 92 +++++++++++++- tests/test_config_multi_host.py | 130 +++++++++++++++++++ tests/test_configure_multi_host.py | 103 +++++++++++++++ tests/test_executor_watch_logs_multi.py | 44 +++++++ tests/test_restart_multi_host.py | 57 +++++++++ tests/test_status_multi_host.py | 87 +++++++++++++ tests/test_update_multi_host.py | 161 ++++++++++++++++++++++++ trobz_deploy/command/configure.py | 60 +++++++-- trobz_deploy/command/restart.py | 42 +++++-- trobz_deploy/command/status.py | 39 ++++-- trobz_deploy/command/update.py | 60 +++++++-- trobz_deploy/utils/config.py | 66 ++++++++++ trobz_deploy/utils/executor.py | 102 ++++++++++++++- 13 files changed, 989 insertions(+), 54 deletions(-) create mode 100644 tests/test_config_multi_host.py create mode 100644 tests/test_configure_multi_host.py create mode 100644 tests/test_executor_watch_logs_multi.py create mode 100644 tests/test_restart_multi_host.py create mode 100644 tests/test_status_multi_host.py create mode 100644 tests/test_update_multi_host.py diff --git a/SPEC.md b/SPEC.md index b4fd2a9..96b5eac 100644 --- a/SPEC.md +++ b/SPEC.md @@ -98,7 +98,7 @@ deploy [--config FILE] configure [] [] [--ty | Argument | Required without config | Description | |----------------|------------------------|------------------------------------------------------------------------------| | `instance_name` | Always | Logical name for the instance (used for paths and service name) | -| `ssh_host` | If not in config | SSH target, or `localhost` / omit to deploy locally without SSH | +| `ssh_host` | If not in config | SSH target, or `localhost` / omit to deploy locally without SSH. In `deploy.yml` only, may instead be a list of hosts — see **Multiple Hosts** below | | `repo_url` | If not in config | Git repository URL (e.g. `git@github.com:org/repo.git`) | **Options** @@ -122,7 +122,9 @@ Steps 2–6 each correspond to a `--steps` / `--except` slug, shown in **bold**. (connecting) always runs regardless of `--steps`/`--except`. 1. **Connect** — if `ssh_host` is set and is not `localhost`, open an SSH connection. - Otherwise all subsequent commands run as local subprocesses. + Otherwise all subsequent commands run as local subprocesses. If `ssh_host` is a list + (`deploy.yml` only), steps 1 onward repeat for each host in list order — see + **Multiple Hosts** below. 2. **`set-up-instance-dir`** — set up `~/` on the target host: - **`python` with `requirements`** (package mode): create the directory with `mkdir -p`. @@ -237,7 +239,7 @@ deploy [--config FILE] update [] [-p ] [--ty | Argument | Required without config | Description | |----------------|------------------------|------------------------------------------------------------------------------| | `instance_name` | Always | Name of the previously configured instance | -| `ssh_host` | If not in config | SSH target, or `localhost` / omit to deploy locally without SSH | +| `ssh_host` | If not in config | SSH target, or `localhost` / omit to deploy locally without SSH. In `deploy.yml` only, may instead be a list of hosts — see **Multiple Hosts** below | **Options** @@ -302,7 +304,9 @@ Steps 5–7 each correspond to a `--steps` / `--except` slug, shown in **bold**. and 8–9 always run regardless of `--steps`/`--except`. 1. **Connect** — if `ssh_host` is set and is not `localhost`, open an SSH connection. - Otherwise all subsequent commands run as local subprocesses. + Otherwise all subsequent commands run as local subprocesses. If `ssh_host` is a list + (`deploy.yml` only), steps 1 onward repeat for each host in list order — see + **Multiple Hosts** below. 2. **Run `pre-update` hooks** — execute all `pre-update` commands in order (non-blocking). @@ -397,7 +401,7 @@ deploy [--config FILE] status [] [-p ] | Argument | Required without config | Description | |----------------|------------------------|------------------------------------------------------------------------------| | `instance_name` | Always | Name of the previously configured instance | -| `ssh_host` | If not in config | SSH target, or `localhost` / omit to deploy locally without SSH | +| `ssh_host` | If not in config | SSH target, or `localhost` / omit to deploy locally without SSH. In `deploy.yml` only, may instead be a list of hosts — see **Multiple Hosts** below | | `ssh_port` | If not in config | SSH port, default 22 | **Output** @@ -411,10 +415,17 @@ Branch: main (abc1234) Unit: active (running) since 2026-03-09 08:12:03 ``` +When `ssh_host` resolves to more than one host (`deploy.yml` list form with 2+ entries), this +block is printed once per host, in list order, each preceded by a `Host: ` line and +separated from the next by a blank line. A single-entry list is treated like a plain string and +prints the block above with no `Host:` line. + **Steps (executed in order)** 1. **Connect** — if `ssh_host` is set and is not `localhost`, open an SSH connection. - Otherwise all subsequent commands run as local subprocesses. + Otherwise all subsequent commands run as local subprocesses. If `ssh_host` is a list + (`deploy.yml` only), steps 1 onward repeat for each host in list order — see + **Multiple Hosts** below. 2. **Git info** — inside `~/`, run: - `git remote get-url origin` → remote URL @@ -485,6 +496,75 @@ The config file is resolved **locally** (on the machine running `deploy`), not o It is **not** committed to the project repository — it lives outside the deployment folder, typically alongside the operator's other deployment scripts or in a private configuration repository. +### Multiple Hosts (`ssh_host` as a list) + +In `deploy.yml`, `ssh_host` may be a list instead of a single string, so that `configure`, +`update`, and `status` can each be rolled out across several hosts under one `instance_name`. +The CLI positional `` argument always takes a single value — list form is only +available via the config file. + +```yaml +# deploy.yml +openerp-myproject-production: + repo_url: git@github.com:org/repo.git + db: myproject_production + ssh_host: + - host2.example.com: + watch: true + steps: + update: pull, venv, db + - host1.example.com: + watch: true + steps: + update: pull, venv + ssh_port: 22 + ssh_user: openerp +``` + +- Each list entry is a single-key mapping: the key is the hostname, the value is an optional + mapping of per-host overrides (`watch`, `steps`). A host with no overrides can be written as a + bare string instead of a mapping. +- `ssh_user` is the SSH user shared by every bare hostname in the list, so individual entries + don't need to repeat `user@host`. An entry that already embeds a user (`user@host`) ignores + `ssh_user`. `ssh_user` is ignored when `ssh_host` is a single string — embed the user in that + string instead, as today. +- `ssh_port` and every other instance-level setting (`type`, `db`, `hooks`, …) stay shared across + all hosts in the list; there is no per-host override for them. + +**Per-host `steps`** — keyed by command name (`configure` or `update`; `status` has no +`--steps`/`--except` option and ignores this key). The value uses the same comma-separated slugs +as that command's `--steps`/`--except` (e.g. `pull`, `venv`, `db` for `update`; see each +command's **Steps** section). It becomes that host's effective `--steps` for the matching +command, with no `--except` for that host. `--steps`/`--except` passed on the CLI still wins +over it for every host, per the usual CLI > config > default precedence — a host's `steps` entry +only takes effect when the CLI was left at its defaults (no `--steps`/`--except`). A host with no +entry for the current command name runs with the default (`all`). + +**Per-host `watch`** — same meaning as the `--watch` flag, scoped to that host. The CLI +`--watch` flag, when passed, forces every host to be watched, overriding any per-host +`watch: false`. + +**Execution order** — hosts are processed **sequentially, in list order**: `deploy` connects to +the first host, runs the selected steps/hooks to completion, then moves to the next. If a host +fails, the command aborts immediately with that host's exit code — later hosts are not attempted. +This lets one host own a step that must not run twice (e.g. `db` in the example above only runs +on `host2.example.com`; `host1.example.com` only refreshes code and the venv), and ensures the +database has migrated before other hosts pick up the new code that depends on it. For `configure` +and `update`, when there is more than one host, each host's turn is announced with a banner before +its steps run: + +``` +=== Host 1/2: host2.example.com === +``` + +A single-entry list runs exactly like a plain string — no banner, no `Host:` line, identical +output to today. + +**Watching multiple hosts** — when more than one host ends up with `watch` enabled (via the CLI +flag or per-host config), their `journalctl` streams (and merged odoo / click-odoo-update logs +where applicable) are interleaved as they arrive and each line is prefixed with a colored +`[hostname]` label, so output from concurrently-tailed hosts stays distinguishable. + --- ## Bundled Templates diff --git a/tests/test_config_multi_host.py b/tests/test_config_multi_host.py new file mode 100644 index 0000000..2e44c77 --- /dev/null +++ b/tests/test_config_multi_host.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import pytest + +from trobz_deploy.utils.config import normalize_hosts, resolve_host_steps + +# --------------------------------------------------------------------------- +# normalize_hosts +# --------------------------------------------------------------------------- + + +def test_none_host_becomes_single_entry(): + assert normalize_hosts(None) == [{"host": None, "watch": None, "steps": {}}] + + +def test_single_string_host_is_untouched_by_ssh_user(): + # ssh_user only applies to list entries; a plain string keeps any embedded user as-is. + assert normalize_hosts("deploy@myserver.example.com", ssh_user="openerp") == [ + {"host": "deploy@myserver.example.com", "watch": None, "steps": {}} + ] + + +def test_list_of_bare_strings_has_no_overrides(): + assert normalize_hosts(["host1.example.com", "host2.example.com"]) == [ + {"host": "host1.example.com", "watch": None, "steps": {}}, + {"host": "host2.example.com", "watch": None, "steps": {}}, + ] + + +def test_list_entry_with_overrides_extracts_watch_and_steps(): + raw = [ + { + "host2.example.com": { + "watch": True, + "steps": {"update": "pull, venv, db"}, + } + }, + {"host1.example.com": {"watch": True, "steps": {"update": "pull, venv"}}}, + ] + + result = normalize_hosts(raw) + + assert result == [ + {"host": "host2.example.com", "watch": True, "steps": {"update": "pull, venv, db"}}, + {"host": "host1.example.com", "watch": True, "steps": {"update": "pull, venv"}}, + ] + + +def test_list_entry_with_null_overrides_is_equivalent_to_bare_string(): + assert normalize_hosts([{"host1.example.com": None}]) == [{"host": "host1.example.com", "watch": None, "steps": {}}] + + +def test_ssh_user_prefixes_bare_hostnames_in_list_form(): + result = normalize_hosts(["host1.example.com"], ssh_user="openerp") + + assert result == [{"host": "openerp@host1.example.com", "watch": None, "steps": {}}] + + +def test_ssh_user_does_not_override_embedded_user(): + result = normalize_hosts(["deploy@host1.example.com"], ssh_user="openerp") + + assert result == [{"host": "deploy@host1.example.com", "watch": None, "steps": {}}] + + +def test_ssh_user_does_not_apply_to_localhost(): + result = normalize_hosts(["localhost"], ssh_user="openerp") + + assert result == [{"host": "localhost", "watch": None, "steps": {}}] + + +def test_invalid_list_entry_type_raises(): + with pytest.raises(ValueError, match="Invalid ssh_host list entry"): + normalize_hosts([123]) + + +def test_multi_key_mapping_entry_raises(): + with pytest.raises(ValueError, match="Invalid ssh_host list entry"): + normalize_hosts([{"host1.example.com": {}, "host2.example.com": {}}]) + + +def test_non_mapping_overrides_raises(): + with pytest.raises(ValueError, match="Invalid ssh_host overrides"): + normalize_hosts([{"host1.example.com": "not-a-mapping"}]) + + +# --------------------------------------------------------------------------- +# resolve_host_steps +# --------------------------------------------------------------------------- + +STEPS = {"pull": "Pull", "venv": "Venv", "db": "DB"} + + +def test_no_host_override_keeps_cli_resolved_steps(): + steps, skip = resolve_host_steps({}, "update", ["all"], [], STEPS) + + assert steps == ["all"] + assert skip == [] + + +def test_host_override_applies_when_cli_did_not_constrain(): + steps, skip = resolve_host_steps({"update": "pull, venv"}, "update", ["all"], [], STEPS) + + assert steps == ["pull", "venv"] + assert skip == [] + + +def test_host_override_for_other_action_is_ignored(): + steps, skip = resolve_host_steps({"configure": "dir"}, "update", ["all"], [], STEPS) + + assert steps == ["all"] + assert skip == [] + + +def test_cli_steps_override_wins_over_host_override(): + steps, skip = resolve_host_steps({"update": "pull, venv"}, "update", ["db"], [], STEPS) + + assert steps == ["db"] + assert skip == [] + + +def test_cli_except_override_wins_over_host_override(): + steps, skip = resolve_host_steps({"update": "pull, venv"}, "update", ["all"], ["db"], STEPS) + + assert steps == ["all"] + assert skip == ["db"] + + +def test_invalid_host_step_slug_raises(): + with pytest.raises(ValueError, match="Invalid ssh_host steps\\.update"): + resolve_host_steps({"update": "bogus"}, "update", ["all"], [], STEPS) diff --git a/tests/test_configure_multi_host.py b/tests/test_configure_multi_host.py new file mode 100644 index 0000000..05718e0 --- /dev/null +++ b/tests/test_configure_multi_host.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from trobz_deploy.cli import app +from trobz_deploy.utils.executor import ExecutorError + + +@pytest.fixture +def runner(): + return CliRunner() + + +def _executor_mock(): + mock = MagicMock() + + def capture_side_effect(cmd, cwd=None): + if cmd == "echo $HOME": + return "/home/deploy" + return "" + + mock.capture.side_effect = capture_side_effect + return mock + + +def _invoke(runner, instance_name, deploy_type, extra_args, cfg, mocks): + with ( + patch("trobz_deploy.command.configure.Executor") as MockExecutor, + patch("trobz_deploy.command.configure.load_config", return_value=cfg), + patch("trobz_deploy.command.configure.render_unit", return_value="[Unit]\n"), + ): + MockExecutor.side_effect = mocks + result = runner.invoke(app, ["configure", instance_name, "--type", deploy_type, *extra_args]) + return result, MockExecutor + + +def test_hosts_are_visited_in_order_with_per_host_steps(runner): + cfg = { + "exec_start": "/usr/bin/myapp", + "ssh_host": [ + {"host2.example.com": {"steps": {"configure": "dir"}}}, + {"host1.example.com": {"steps": {"configure": "unit"}}}, + ], + } + mock1, mock2 = _executor_mock(), _executor_mock() + + result, MockExecutor = _invoke(runner, "service-myapp-production", "service", [], cfg, [mock1, mock2]) + + assert result.exit_code == 0 + assert MockExecutor.call_args_list[0].args[0] == "host2.example.com" + assert MockExecutor.call_args_list[1].args[0] == "host1.example.com" + assert "=== Host 1/2: host2.example.com ===" in result.output + assert "=== Host 2/2: host1.example.com ===" in result.output + + commands1 = [c.args[0] for c in mock1.run.call_args_list] + commands2 = [c.args[0] for c in mock2.run.call_args_list] + assert any("mkdir -p" in c for c in commands1) + assert not any("mkdir -p" in c for c in commands2) + + +def test_aborts_immediately_on_first_host_failure(runner): + cfg = {"repo_url": "git@example.com:org/myapp.git", "ssh_host": ["host1.example.com", "host2.example.com"]} + mock1 = _executor_mock() + + def run_side_effect(cmd, cwd=None, check=True, dry_run=False): + if cmd.startswith("test -d") or cmd.startswith("test -f"): + msg = "not found" + raise ExecutorError(msg) + if cmd.startswith("git clone"): + msg = "network unreachable" + raise ExecutorError(msg) + return "" + + mock1.run.side_effect = run_side_effect + mock2 = _executor_mock() + + result, MockExecutor = _invoke(runner, "odoo-myapp-staging", "odoo", [], cfg, [mock1, mock2]) + + assert result.exit_code == 1 + assert MockExecutor.call_count == 1 + + +def test_multiple_watched_hosts_dispatch_to_watch_logs_multi(runner): + cfg = { + "exec_start": "/usr/bin/myapp", + "ssh_host": [ + {"host2.example.com": {"watch": True}}, + {"host1.example.com": {"watch": True}}, + ], + } + mock1, mock2 = _executor_mock(), _executor_mock() + + with patch("trobz_deploy.command.configure.watch_logs_multi") as mock_watch_multi: + result, _ = _invoke(runner, "service-myapp-production", "service", [], cfg, [mock1, mock2]) + + assert result.exit_code == 0 + mock_watch_multi.assert_called_once_with([ + (mock1, "service", "service-myapp-production"), + (mock2, "service", "service-myapp-production"), + ]) diff --git a/tests/test_executor_watch_logs_multi.py b/tests/test_executor_watch_logs_multi.py new file mode 100644 index 0000000..3195bf1 --- /dev/null +++ b/tests/test_executor_watch_logs_multi.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +from trobz_deploy.utils.executor import Executor, watch_logs_multi + + +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 test_no_targets_is_a_no_op(): + watch_logs_multi([]) + + +def test_single_target_behaves_like_watch_logs(): + executor = _executor() + + watch_logs_multi([(executor, "python", "service-myapp-production")]) + + executor.stream.assert_called_once() + cmd = executor.stream.call_args[0][0] + assert "journalctl --user -u service-myapp-production -f -o short-iso" in cmd + + +def test_multiple_targets_interleave_with_host_labels(capsys): + executor1 = Executor("host1.example.com") + executor1.stream_lines = MagicMock(return_value=iter(["line-a"])) # type: ignore[method-assign] + executor2 = Executor("host2.example.com") + executor2.stream_lines = MagicMock(return_value=iter(["line-b"])) # type: ignore[method-assign] + + watch_logs_multi([ + (executor1, "python", "service-a"), + (executor2, "python", "service-b"), + ]) + + out = capsys.readouterr().out + assert "[host1.example.com] line-a" in out + assert "[host2.example.com] line-b" in out diff --git a/tests/test_restart_multi_host.py b/tests/test_restart_multi_host.py new file mode 100644 index 0000000..0a82bb5 --- /dev/null +++ b/tests/test_restart_multi_host.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from trobz_deploy.cli import app + + +@pytest.fixture +def runner(): + return CliRunner() + + +def _invoke(runner, extra_args, cfg, mocks): + with ( + patch("trobz_deploy.command.restart.Executor") as MockExecutor, + patch("trobz_deploy.command.restart.load_config", return_value=cfg), + ): + MockExecutor.side_effect = mocks + result = runner.invoke(app, ["restart", "service-myapp-production", *extra_args]) + return result, MockExecutor + + +def test_restarts_each_host_in_order(runner): + cfg = {"ssh_host": ["host1.example.com", "host2.example.com"]} + mock1, mock2 = MagicMock(), MagicMock() + + result, MockExecutor = _invoke(runner, [], cfg, [mock1, mock2]) + + assert result.exit_code == 0 + assert MockExecutor.call_args_list[0].args[0] == "host1.example.com" + assert MockExecutor.call_args_list[1].args[0] == "host2.example.com" + mock1.run.assert_called_once_with("systemctl --user restart service-myapp-production") + mock2.run.assert_called_once_with("systemctl --user restart service-myapp-production") + assert "=== Host 1/2: host1.example.com ===" in result.output + assert "=== Host 2/2: host2.example.com ===" in result.output + + +def test_multiple_watched_hosts_dispatch_to_watch_logs_multi(runner): + cfg = { + "ssh_host": [ + {"host1.example.com": {"watch": True}}, + {"host2.example.com": {"watch": True}}, + ], + } + mock1, mock2 = MagicMock(), MagicMock() + + with patch("trobz_deploy.command.restart.watch_logs_multi") as mock_watch_multi: + result, _ = _invoke(runner, [], cfg, [mock1, mock2]) + + assert result.exit_code == 0 + mock_watch_multi.assert_called_once_with([ + (mock1, "python", "service-myapp-production"), + (mock2, "python", "service-myapp-production"), + ]) diff --git a/tests/test_status_multi_host.py b/tests/test_status_multi_host.py new file mode 100644 index 0000000..06b3ee9 --- /dev/null +++ b/tests/test_status_multi_host.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from trobz_deploy.cli import app + + +@pytest.fixture +def runner(): + return CliRunner() + + +def _executor_mock(): + mock = MagicMock() + mock.capture.side_effect = [ + "/home/deploy", # echo $HOME + "ActiveState=active\nSubState=running\nActiveEnterTimestamp=", # systemd props + ] + return mock + + +def _invoke(runner, extra_args, cfg, mocks): + with ( + patch("trobz_deploy.command.status.Executor") as MockExecutor, + patch("trobz_deploy.command.status.load_config", return_value=cfg), + ): + MockExecutor.side_effect = mocks + result = runner.invoke(app, ["status", "service-myapp-production", *extra_args]) + return result, MockExecutor + + +def test_each_host_prints_its_own_labeled_block(runner): + cfg = {"ssh_host": ["host1.example.com", "host2.example.com"]} + mock1, mock2 = _executor_mock(), _executor_mock() + + result, MockExecutor = _invoke(runner, [], cfg, [mock1, mock2]) + + assert result.exit_code == 0 + assert MockExecutor.call_args_list[0].args[0] == "host1.example.com" + assert MockExecutor.call_args_list[1].args[0] == "host2.example.com" + assert "Host: host1.example.com" in result.output + assert "Host: host2.example.com" in result.output + # host1's block must be fully printed before host2's + assert result.output.index("host1.example.com") < result.output.index("host2.example.com") + + +def test_single_host_list_has_no_host_label(runner): + cfg = {"ssh_host": ["host1.example.com"]} + mock1 = _executor_mock() + + result, _ = _invoke(runner, [], cfg, [mock1]) + + assert result.exit_code == 0 + assert "Host:" not in result.output + + +def test_per_host_watch_with_single_target_falls_back_to_watch_logs(runner): + cfg = {"ssh_host": [{"host1.example.com": {"watch": True}}, "host2.example.com"]} + mock1, mock2 = _executor_mock(), _executor_mock() + + result, _ = _invoke(runner, [], cfg, [mock1, mock2]) + + assert result.exit_code == 0 + mock1.watch_logs.assert_called_once_with("python", "service-myapp-production") + mock2.watch_logs.assert_not_called() + + +def test_multiple_watched_hosts_dispatch_to_watch_logs_multi(runner): + cfg = { + "ssh_host": [ + {"host1.example.com": {"watch": True}}, + {"host2.example.com": {"watch": True}}, + ], + } + mock1, mock2 = _executor_mock(), _executor_mock() + + with patch("trobz_deploy.command.status.watch_logs_multi") as mock_watch_multi: + result, _ = _invoke(runner, [], cfg, [mock1, mock2]) + + assert result.exit_code == 0 + mock_watch_multi.assert_called_once_with([ + (mock1, "python", "service-myapp-production"), + (mock2, "python", "service-myapp-production"), + ]) diff --git a/tests/test_update_multi_host.py b/tests/test_update_multi_host.py new file mode 100644 index 0000000..1cb00d6 --- /dev/null +++ b/tests/test_update_multi_host.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from trobz_deploy.cli import app +from trobz_deploy.utils.executor import ExecutorError + + +@pytest.fixture +def runner(): + return CliRunner() + + +def _executor_mock(): + mock = MagicMock() + + def capture_side_effect(cmd, cwd=None): + if cmd == "echo $HOME": + return "/home/deploy" + return "" + + mock.capture.side_effect = capture_side_effect + return mock + + +def _invoke(runner, instance_name, deploy_type, extra_args, cfg, mocks): + with ( + patch("trobz_deploy.command.update.Executor") as MockExecutor, + patch("trobz_deploy.command.update.load_config", return_value=cfg), + ): + MockExecutor.side_effect = mocks + result = runner.invoke( + app, + ["update", instance_name, "--type", deploy_type, *extra_args, "--ignore-hooks"], + ) + return result, MockExecutor + + +def test_hosts_are_visited_in_order_with_per_host_steps(runner): + cfg = { + "ssh_host": [ + {"host2.example.com": {"steps": {"update": "pull, venv, db"}}}, + {"host1.example.com": {"steps": {"update": "pull, venv"}}}, + ], + } + mock1, mock2 = _executor_mock(), _executor_mock() + + result, MockExecutor = _invoke(runner, "odoo-myapp-staging", "odoo", [], cfg, [mock1, mock2]) + + assert result.exit_code == 0 + assert MockExecutor.call_args_list[0].args[0] == "host2.example.com" + assert MockExecutor.call_args_list[1].args[0] == "host1.example.com" + assert "=== Host 1/2: host2.example.com ===" in result.output + assert "=== Host 2/2: host1.example.com ===" in result.output + + commands1 = [c.args[0] for c in mock1.run.call_args_list] + commands2 = [c.args[0] for c in mock2.run.call_args_list] + assert any("click-odoo-update" in c for c in commands1) + assert not any("click-odoo-update" in c for c in commands2) + + +def test_cli_steps_override_wins_over_every_host_override(runner): + cfg = { + "ssh_host": [ + {"host2.example.com": {"steps": {"update": "pull, venv, db"}}}, + {"host1.example.com": {"steps": {"update": "pull, venv"}}}, + ], + } + mock1, mock2 = _executor_mock(), _executor_mock() + + result, _ = _invoke(runner, "odoo-myapp-staging", "odoo", ["--steps", "pull"], cfg, [mock1, mock2]) + + assert result.exit_code == 0 + for mock_exec in (mock1, mock2): + commands = [c.args[0] for c in mock_exec.run.call_args_list] + assert any("git pull --recurse-submodules" in c for c in commands) + assert not any("odoo-venv update" in c for c in commands) + assert not any("click-odoo-update" in c for c in commands) + + +def test_single_host_list_does_not_print_host_header(runner): + cfg = {"ssh_host": ["host1.example.com"]} + mock1 = _executor_mock() + + result, _ = _invoke(runner, "odoo-myapp-staging", "odoo", [], cfg, [mock1]) + + assert result.exit_code == 0 + assert "=== Host" not in result.output + + +def test_aborts_immediately_on_first_host_failure(runner): + cfg = {"ssh_host": ["host1.example.com", "host2.example.com"]} + mock1 = _executor_mock() + + def run_side_effect(cmd, cwd=None, check=True, dry_run=False): + if cmd.startswith("test -d"): + return "" + if "git pull" in cmd: + msg = "network unreachable" + raise ExecutorError(msg) + return "" + + mock1.run.side_effect = run_side_effect + mock2 = _executor_mock() + + result, MockExecutor = _invoke(runner, "odoo-myapp-staging", "odoo", [], cfg, [mock1, mock2]) + + assert result.exit_code == 1 + assert MockExecutor.call_count == 1 + + +def test_per_host_watch_with_single_target_falls_back_to_watch_logs(runner): + cfg = { + "ssh_host": [ + {"host2.example.com": {"watch": True}}, + "host1.example.com", + ], + } + mock1, mock2 = _executor_mock(), _executor_mock() + + result, _ = _invoke(runner, "service-myapp-production", "service", [], cfg, [mock1, mock2]) + + assert result.exit_code == 0 + mock1.watch_logs.assert_called_once_with("service", "service-myapp-production") + mock2.watch_logs.assert_not_called() + + +def test_multiple_watched_hosts_dispatch_to_watch_logs_multi(runner): + cfg = { + "ssh_host": [ + {"host2.example.com": {"watch": True}}, + {"host1.example.com": {"watch": True}}, + ], + } + mock1, mock2 = _executor_mock(), _executor_mock() + + with patch("trobz_deploy.command.update.watch_logs_multi") as mock_watch_multi: + result, _ = _invoke(runner, "service-myapp-production", "service", [], cfg, [mock1, mock2]) + + assert result.exit_code == 0 + mock_watch_multi.assert_called_once_with([ + (mock1, "service", "service-myapp-production"), + (mock2, "service", "service-myapp-production"), + ]) + + +def test_cli_watch_flag_forces_watch_on_every_host(runner): + cfg = {"ssh_host": ["host1.example.com", "host2.example.com"]} + mock1, mock2 = _executor_mock(), _executor_mock() + + with patch("trobz_deploy.command.update.watch_logs_multi") as mock_watch_multi: + result, _ = _invoke(runner, "service-myapp-production", "service", ["--watch"], cfg, [mock1, mock2]) + + assert result.exit_code == 0 + mock_watch_multi.assert_called_once_with([ + (mock1, "service", "service-myapp-production"), + (mock2, "service", "service-myapp-production"), + ]) diff --git a/trobz_deploy/command/configure.py b/trobz_deploy/command/configure.py index 53f8c10..f031774 100644 --- a/trobz_deploy/command/configure.py +++ b/trobz_deploy/command/configure.py @@ -5,8 +5,16 @@ import typer -from trobz_deploy.utils.config import DeployType, load_config, parse_step_option, resolve_options, validate_step_slugs -from trobz_deploy.utils.executor import Executor, ExecutorError +from trobz_deploy.utils.config import ( + DeployType, + load_config, + normalize_hosts, + parse_step_option, + resolve_host_steps, + resolve_options, + validate_step_slugs, +) +from trobz_deploy.utils.executor import Executor, ExecutorError, watch_logs_multi from trobz_deploy.utils.render import render_unit from trobz_deploy.utils.venv import setup_odoo_venv, setup_package_venv, setup_python_venv @@ -65,9 +73,8 @@ def _perform_configure( # noqa: C901 instance_name: str, eff_steps: list[str], eff_skip_steps: list[str], - watch: bool, dry_run: bool, -) -> None: +) -> Executor: def _run_step(slug: str) -> bool: return ("all" in eff_steps or slug in eff_steps) and slug not in eff_skip_steps @@ -230,14 +237,10 @@ def _run_step(slug: str) -> bool: fg="yellow", ) - if watch: - if dry_run: - typer.secho("Skipping --watch: no service was started in dry-run mode.", fg="yellow") - else: - executor.watch_logs(eff_type, instance_name) + return executor -def configure( +def configure( # noqa: C901 ctx: typer.Context, instance_name: Annotated[str, typer.Argument()], ssh_host: Annotated[str | None, typer.Argument()] = None, @@ -321,12 +324,47 @@ def configure( opts["verbose"] = ctx.obj["verbose"] opts["recreate"] = recreate + try: + host_specs = normalize_hosts(opts.get("ssh_host"), opts.get("ssh_user")) + except ValueError as exc: + typer.echo(typer.style(str(exc), fg="red"), err=True) + raise typer.Exit(code=1) from exc + if dry_run: typer.secho("\nDry run: no writing/destructive commands will be executed.", fg="cyan") - _perform_configure(opts, instance_name, eff_steps, eff_skip_steps, watch, dry_run) + multi_host = len(host_specs) > 1 + watch_targets: list[tuple[Executor, str, str]] = [] + + for i, host_spec in enumerate(host_specs): + if multi_host: + typer.secho( + f"\n=== Host {i + 1}/{len(host_specs)}: {host_spec['host'] or 'localhost'} ===", fg="blue", bold=True + ) + + try: + host_eff_steps, host_eff_skip_steps = resolve_host_steps( + host_spec["steps"], "configure", eff_steps, eff_skip_steps, CONFIGURE_STEPS + ) + except ValueError as exc: + typer.echo(typer.style(str(exc), fg="red"), err=True) + raise typer.Exit(code=1) from exc + + host_opts = dict(opts) + host_opts["ssh_host"] = host_spec["host"] + + executor = _perform_configure(host_opts, instance_name, host_eff_steps, host_eff_skip_steps, dry_run) + + if watch or host_spec["watch"]: + watch_targets.append((executor, opts["type"], instance_name)) if dry_run: typer.secho(f"\nDry run complete: instance {instance_name!r} was not changed.", fg="green") else: typer.secho(f"\nInstance {instance_name!r} configured successfully.", fg="green") + + if watch_targets: + if dry_run: + typer.secho("Skipping --watch: no service was started in dry-run mode.", fg="yellow") + else: + watch_logs_multi(watch_targets) diff --git a/trobz_deploy/command/restart.py b/trobz_deploy/command/restart.py index 5c4d0bf..7ed48fd 100644 --- a/trobz_deploy/command/restart.py +++ b/trobz_deploy/command/restart.py @@ -4,8 +4,8 @@ import typer -from trobz_deploy.utils.config import DeployType, load_config, resolve_options -from trobz_deploy.utils.executor import Executor, ExecutorError +from trobz_deploy.utils.config import DeployType, load_config, normalize_hosts, resolve_options +from trobz_deploy.utils.executor import Executor, ExecutorError, watch_logs_multi def restart( @@ -42,20 +42,36 @@ def restart( typer.echo(typer.style(str(exc), fg="red"), err=True) raise typer.Exit(code=1) from exc - 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) - - typer.secho(f"\nRestarting {instance_name!r}…", fg="green") try: - executor.run(f"systemctl --user restart {instance_name}") - except ExecutorError as exc: - typer.echo(typer.style(f"Restart failed: {exc}", fg="red"), err=True) + host_specs = normalize_hosts(opts.get("ssh_host"), opts.get("ssh_user")) + except ValueError as exc: + typer.echo(typer.style(str(exc), fg="red"), err=True) raise typer.Exit(code=1) from exc - typer.secho(f"\nInstance {instance_name!r} restarted.", fg="green") + multi_host = len(host_specs) > 1 + watch_targets: list[tuple[Executor, str, str]] = [] + + for i, host_spec in enumerate(host_specs): + if multi_host: + typer.secho( + f"\n=== Host {i + 1}/{len(host_specs)}: {host_spec['host'] or 'localhost'} ===", fg="blue", bold=True + ) + + executor = Executor(host_spec["host"], ctx.obj["verbose"], ssh_port=eff_ssh_port) + + typer.secho(f"\nRestarting {instance_name!r}…", fg="green") + try: + executor.run(f"systemctl --user restart {instance_name}") + except ExecutorError as exc: + typer.echo(typer.style(f"Restart failed: {exc}", fg="red"), err=True) + raise typer.Exit(code=1) from exc + + typer.secho(f"\nInstance {instance_name!r} restarted.", fg="green") + + if watch or host_spec["watch"]: + watch_targets.append((executor, opts["type"], instance_name)) - if watch: - executor.watch_logs(eff_type, instance_name) + if watch_targets: + watch_logs_multi(watch_targets) diff --git a/trobz_deploy/command/status.py b/trobz_deploy/command/status.py index 35db69f..c6ab6b5 100644 --- a/trobz_deploy/command/status.py +++ b/trobz_deploy/command/status.py @@ -4,8 +4,8 @@ import typer -from trobz_deploy.utils.config import DeployType, load_config, resolve_options -from trobz_deploy.utils.executor import Executor, ExecutorError +from trobz_deploy.utils.config import DeployType, load_config, normalize_hosts, resolve_options +from trobz_deploy.utils.executor import Executor, ExecutorError, watch_logs_multi def _get_unit_line(executor: Executor, instance_name: str) -> str: @@ -41,11 +41,10 @@ def _get_git_info(executor: Executor, instance_path: str) -> tuple[str, str, str def _perform_status( opts: dict[str, Any], instance_name: str, - watch: bool, -) -> None: + host_label: str | None = None, +) -> Executor: 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, opts["verbose"], ssh_port=eff_ssh_port) home_dir = executor.capture("echo $HOME") @@ -73,14 +72,15 @@ def _perform_status( # Step 4: systemd unit status unit_line = _get_unit_line(executor, instance_name) + if host_label: + typer.echo(f"Host: {host_label}") typer.echo(f"Instance: {instance_name}") if has_repo: typer.echo(f"Remote: {remote_url}") typer.echo(f"Branch: {branch} ({commit})") typer.echo(f"Unit: {unit_line}") - if watch: - executor.watch_logs(eff_type, instance_name) + return executor def status( @@ -118,4 +118,27 @@ def status( raise typer.Exit(code=1) from exc opts["verbose"] = ctx.obj["verbose"] - _perform_status(opts, instance_name, watch) + try: + host_specs = normalize_hosts(opts.get("ssh_host"), opts.get("ssh_user")) + except ValueError as exc: + typer.echo(typer.style(str(exc), fg="red"), err=True) + raise typer.Exit(code=1) from exc + + multi_host = len(host_specs) > 1 + watch_targets: list[tuple[Executor, str, str]] = [] + + for i, host_spec in enumerate(host_specs): + if multi_host and i: + typer.echo() + + host_opts = dict(opts) + host_opts["ssh_host"] = host_spec["host"] + host_label = (host_spec["host"] or "localhost") if multi_host else None + + executor = _perform_status(host_opts, instance_name, host_label) + + if watch or host_spec["watch"]: + watch_targets.append((executor, opts["type"], instance_name)) + + if watch_targets: + watch_logs_multi(watch_targets) diff --git a/trobz_deploy/command/update.py b/trobz_deploy/command/update.py index e3b96b1..66b9e77 100644 --- a/trobz_deploy/command/update.py +++ b/trobz_deploy/command/update.py @@ -5,8 +5,16 @@ import typer from trobz_deploy.utils.addons import get_addons_path -from trobz_deploy.utils.config import DeployType, load_config, parse_step_option, resolve_options, validate_step_slugs -from trobz_deploy.utils.executor import Executor, ExecutorError +from trobz_deploy.utils.config import ( + DeployType, + load_config, + normalize_hosts, + parse_step_option, + resolve_host_steps, + resolve_options, + validate_step_slugs, +) +from trobz_deploy.utils.executor import Executor, ExecutorError, watch_logs_multi from trobz_deploy.utils.venv import get_odoo_version, setup_python_deps, upgrade_package UPDATE_STEPS = { @@ -21,9 +29,8 @@ def _perform_update( # noqa: C901 instance_name: str, eff_steps: list[str], eff_skip_steps: list[str], - watch: bool, dry_run: bool, -) -> None: +) -> Executor: def _run_step(slug: str) -> bool: return ("all" in eff_steps or slug in eff_steps) and slug not in eff_skip_steps @@ -195,14 +202,10 @@ def run_hooks(hook_name: str) -> bool: run_hooks("post-update") run_hooks("post-update-success") - if watch: - if dry_run: - typer.secho("Skipping --watch: no service was restarted in dry-run mode.", fg="yellow") - else: - executor.watch_logs(eff_type, instance_name) + return executor -def update( +def update( # noqa: C901 ctx: typer.Context, instance_name: Annotated[str, typer.Argument()], ssh_host: Annotated[str | None, typer.Argument()] = None, @@ -327,12 +330,47 @@ def update( opts["update_all"] = update_all opts["modules"] = modules + try: + host_specs = normalize_hosts(opts.get("ssh_host"), opts.get("ssh_user")) + except ValueError as exc: + typer.echo(typer.style(str(exc), fg="red"), err=True) + raise typer.Exit(code=1) from exc + if dry_run: typer.secho("\nDry run: no writing/destructive commands will be executed.", fg="cyan") - _perform_update(opts, instance_name, eff_steps, eff_skip_steps, watch, dry_run) + multi_host = len(host_specs) > 1 + watch_targets: list[tuple[Executor, str, str]] = [] + + for i, host_spec in enumerate(host_specs): + if multi_host: + typer.secho( + f"\n=== Host {i + 1}/{len(host_specs)}: {host_spec['host'] or 'localhost'} ===", fg="blue", bold=True + ) + + try: + host_eff_steps, host_eff_skip_steps = resolve_host_steps( + host_spec["steps"], "update", eff_steps, eff_skip_steps, UPDATE_STEPS + ) + except ValueError as exc: + typer.echo(typer.style(str(exc), fg="red"), err=True) + raise typer.Exit(code=1) from exc + + host_opts = dict(opts) + host_opts["ssh_host"] = host_spec["host"] + + executor = _perform_update(host_opts, instance_name, host_eff_steps, host_eff_skip_steps, dry_run) + + if watch or host_spec["watch"]: + watch_targets.append((executor, opts["type"], instance_name)) if dry_run: typer.secho(f"\nDry run complete: instance {instance_name!r} was not updated.", fg="green") else: typer.secho(f"\nInstance {instance_name!r} updated successfully.", fg="green") + + if watch_targets: + if dry_run: + typer.secho("Skipping --watch: no service was restarted in dry-run mode.", fg="yellow") + else: + watch_logs_multi(watch_targets) diff --git a/trobz_deploy/utils/config.py b/trobz_deploy/utils/config.py index a6d439d..0beea33 100644 --- a/trobz_deploy/utils/config.py +++ b/trobz_deploy/utils/config.py @@ -120,6 +120,47 @@ def resolve_options( return resolved +def _apply_ssh_user(host: str, ssh_user: str | None) -> str: + """Prefix a bare hostname with ``ssh_user`` unless it already embeds a user or is localhost.""" + if ssh_user and host != "localhost" and "@" not in host: + return f"{ssh_user}@{host}" + return host + + +def normalize_hosts(ssh_host: Any, ssh_user: str | None = None) -> list[dict[str, Any]]: + """Normalize the resolved ``ssh_host`` value into a list of per-host dicts. + + ``ssh_host`` may be a single string/``None`` (the common case) or a list of hosts + for rolling a command out across several machines. Each list entry is either a + bare hostname or a single-key mapping ``{hostname: {watch: bool, steps: {...}}}``. + + Returns a list of dicts with keys ``host``, ``watch`` (bool | None override), and + ``steps`` (dict mapping command name -> comma-separated slug string). + """ + if not isinstance(ssh_host, list): + return [{"host": ssh_host, "watch": None, "steps": {}}] + + hosts: list[dict[str, Any]] = [] + for entry in ssh_host: + if isinstance(entry, str): + host, overrides = entry, {} + elif isinstance(entry, dict) and len(entry) == 1: + ((host, overrides),) = entry.items() + overrides = overrides or {} + else: + msg = f"Invalid ssh_host list entry: {entry!r}. Expected a hostname string or a single-key mapping." + raise ValueError(msg) + if not isinstance(overrides, dict): + msg = f"Invalid ssh_host overrides for {host!r}: {overrides!r}. Expected a mapping." + raise ValueError(msg) # noqa: TRY004 (kept as ValueError: callers catch ValueError, not TypeError) + hosts.append({ + "host": _apply_ssh_user(host, ssh_user), + "watch": overrides.get("watch"), + "steps": overrides.get("steps") or {}, + }) + return hosts + + def parse_step_option(value: str | None) -> list[str]: """Split a comma-separated option value into a list of trimmed, non-empty slugs.""" if not value: @@ -135,3 +176,28 @@ def validate_step_slugs(option_name: str, slugs: list[str], valid_steps: dict[st choices = ", ".join((*valid_steps, "all") if allow_all else valid_steps) msg = f"Invalid {option_name} value(s): {', '.join(invalid)}. Available steps: {choices}" raise ValueError(msg) + + +def resolve_host_steps( + host_steps: dict[str, str], + action: str, + eff_steps: list[str], + eff_skip_steps: list[str], + valid_steps: dict[str, str], +) -> tuple[list[str], list[str]]: + """Resolve a single host's effective --steps/--except for *action*. + + *host_steps* is a host's ``steps`` override mapping (command name -> comma-separated + slugs), as produced by ``normalize_hosts``. If the CLI explicitly constrained + --steps/--except (i.e. *eff_steps*/*eff_skip_steps* are not the untouched defaults), + or the host has no override for *action*, the CLI-resolved values win unchanged. + Otherwise the host's value becomes its --steps, with no --except. + """ + cli_overrides = eff_steps != ["all"] or bool(eff_skip_steps) + host_steps_value = host_steps.get(action) + if cli_overrides or not host_steps_value: + return eff_steps, eff_skip_steps + + parsed = parse_step_option(host_steps_value) + validate_step_slugs(f"ssh_host steps.{action}", parsed, valid_steps, allow_all=True) + return parsed, [] diff --git a/trobz_deploy/utils/executor.py b/trobz_deploy/utils/executor.py index f5980b1..ea4d4ca 100644 --- a/trobz_deploy/utils/executor.py +++ b/trobz_deploy/utils/executor.py @@ -2,6 +2,8 @@ import base64 import subprocess +import threading +from collections.abc import Iterator import typer @@ -132,7 +134,18 @@ def watch_logs(self, eff_type: str, instance_name: str) -> None: written by ``click-odoo-update``. Any streams found are tailed concurrently. """ typer.secho("\nWatching service logs (Ctrl+C to stop)…", fg="cyan") + cmd = self.build_watch_command(eff_type, instance_name) + try: + self.stream(cmd) + except KeyboardInterrupt: + typer.echo() + def build_watch_command(self, eff_type: str, instance_name: str) -> str: + """Build the merged journalctl/log-tail shell command used by ``watch_logs``. + + Exposed separately so multiple hosts can be watched concurrently via + ``merge_stream_lines`` instead of each blocking the terminal in turn. + """ log_file: str | None = None upgrade_log_file: str | None = None if eff_type == "odoo": @@ -169,11 +182,31 @@ def watch_logs(self, eff_type: str, instance_name: str) -> None: 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() + cmd = f"( {' & '.join(streams)} & wait )" if len(streams) > 1 else streams[0] + return cmd + + def stream_lines(self, command: str, cwd: str | None = None) -> Iterator[str]: + """Run a long-lived command and yield its stdout one line at a time. + + Useful for commands like ``tail -f`` where output must be processed + incrementally rather than printed wholesale to the terminal. + """ + argv = self._build_argv(command, cwd) + is_remote = isinstance(argv, list) + if self.verbose: + display = argv[-1] if is_remote else command + typer.echo(f"$ {display}", err=True) + with subprocess.Popen( # noqa: S603 + argv, + shell=not is_remote, + cwd=cwd if not is_remote else None, + stdout=subprocess.PIPE, + text=True, + bufsize=1, + ) as proc: + assert proc.stdout is not None # noqa: S101 + for line in proc.stdout: + yield line.rstrip("\n") def stream(self, command: str, cwd: str | None = None) -> None: """Run a long-lived streaming command (e.g. journalctl -f). @@ -206,3 +239,62 @@ def write_file(self, content: str, remote_path: str, dry_run: bool = False) -> N self.verbose = False self.run(f"echo '{b64}' | base64 -d > {remote_path}") self.verbose = verbose + + +# ANSI color cycle for merge_stream_lines labels (cyan, green, yellow, magenta, blue) +_LABEL_COLORS = ["36", "32", "33", "35", "34"] + + +def merge_stream_lines(streams: list[tuple[Executor, str]], prefix: bool = True) -> None: + """Stream output from multiple (executor, command) pairs concurrently. + + Each stream runs in its own thread. Lines are printed as they arrive, + optionally prefixed with a colored ``[host]`` label so you can tell them apart. + Blocks until all streams end or until a KeyboardInterrupt. + """ + lock = threading.Lock() + + def _pump(executor: Executor, command: str, label: str, color: str) -> None: + colored_label = f"\x1b[{color}m[{label}]\x1b[0m" + try: + for line in executor.stream_lines(command): + with lock: + typer.echo(f"{colored_label} {line}" if prefix else line) + except Exception as e: + typer.secho(f"{colored_label} Exception in _pump {e}", fg="red") + + threads: list[threading.Thread] = [] + for i, (executor, command) in enumerate(streams): + label = executor.ssh_host or "localhost" + color = _LABEL_COLORS[i % len(_LABEL_COLORS)] + t = threading.Thread(target=_pump, args=(executor, command, label, color), daemon=True) + t.start() + threads.append(t) + + try: + for t in threads: + t.join() + except KeyboardInterrupt: + typer.echo() + + +def watch_logs_multi(targets: list[tuple[Executor, str, str]]) -> None: + """Watch logs for one or more ``(executor, eff_type, instance_name)`` targets. + + A single target behaves exactly like ``Executor.watch_logs``. With more than one, + each host's merged journalctl/log stream is tailed concurrently and interleaved + with a colored ``[host]`` label via ``merge_stream_lines``. + """ + if not targets: + return + if len(targets) == 1: + executor, eff_type, instance_name = targets[0] + executor.watch_logs(eff_type, instance_name) + return + + typer.secho("\nWatching service logs for multiple hosts (Ctrl+C to stop)…", fg="cyan") + streams = [ + (executor, executor.build_watch_command(eff_type, instance_name)) + for executor, eff_type, instance_name in targets + ] + merge_stream_lines(streams)