diff --git a/site-docs/docs/cli-reference.md b/site-docs/docs/cli-reference.md index 3eedc0c..fe7ce3f 100644 --- a/site-docs/docs/cli-reference.md +++ b/site-docs/docs/cli-reference.md @@ -58,9 +58,10 @@ $ deploy configure [OPTIONS] INSTANCE_NAME [SSH_HOST] [REPO_URL] * `-p, --port INTEGER`: SSH port on the remote host. * `--repo-subdir TEXT`: Subdirectory within the repo to use as the service root (for monorepos). * `--repo-branch TEXT`: Git branch to clone and track (defaults to the repository's default branch). +* `--recreate / --no-recreate`: Re-create existing artifacts (venv, odoo-config, systemd unit) instead of skipping them. \[default: no-recreate\] * `--watch`: Stream service logs with journalctl after a successful configure. Also merge with odoo and click-odoo-update logs if applicable. -* `--steps TEXT`: Comma-separated steps to run, or 'all'. Available: dir, pg, gitaggregate, venv, unit. \[default: all\] -* `--except TEXT`: Comma-separated steps to skip. Available: dir, pg, gitaggregate, venv, unit. +* `--steps TEXT`: Comma-separated steps to run, or 'all'. Available: dir, pg, gitaggregate, venv, config, unit. \[default: all\] +* `--except TEXT`: Comma-separated steps to skip. Available: dir, pg, gitaggregate, venv, config, unit. * `--dry-run`: Go through all steps without running any writing/destructive commands. * `--help`: Show this message and exit. diff --git a/tests/test_configure_config.py b/tests/test_configure_config.py new file mode 100644 index 0000000..416ddd0 --- /dev/null +++ b/tests/test_configure_config.py @@ -0,0 +1,146 @@ +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(*, conf_exists: bool): + """Executor mock where config/odoo.conf may or may not already exist.""" + mock = MagicMock() + mock.capture.side_effect = lambda cmd, cwd=None, dry_run=False: "/home/deploy" if cmd == "echo $HOME" else "" + + def run_side_effect(cmd, cwd=None, check=True, dry_run=False): + if not conf_exists and cmd.startswith("test -f") and cmd.endswith("odoo.conf"): + msg = "not found" + raise ExecutorError(msg) + return "" + + mock.run.side_effect = run_side_effect + return mock + + +def _invoke(runner, extra_args, cfg, *, conf_exists): + with ( + patch("trobz_deploy.command.configure.Executor") as MockExecutor, + patch("trobz_deploy.command.configure.load_config", return_value=cfg), + ): + mock_exec = _executor_mock(conf_exists=conf_exists) + MockExecutor.return_value = mock_exec + result = runner.invoke( + app, + ["configure", "odoo-myapp-staging", "--type", "odoo", "--steps", "config", *extra_args], + ) + return result, mock_exec + + +def _commands(mock_exec) -> list[str]: + return [call.args[0] for call in mock_exec.run.call_args_list] + + +def test_config_generates_with_defaults_and_user_overrides(runner): + cfg = {"version": "17.0", "config": {"db_password": "secret"}} + result, mock_exec = _invoke(runner, [], cfg, conf_exists=False) + + assert result.exit_code == 0 + create = next(c for c in _commands(mock_exec) if c.startswith("odoo-config create")) + assert "--version 17.0" in create + assert "config/odoo.conf" in create + assert "--db_user=odoo-myapp-staging" in create + assert "--report.url=http://odoo-myapp-staging:8069" in create + assert "--http_interface=odoo-myapp-staging" in create + assert "--db_password=secret" in create + assert "--preset staging" in create # derived from the instance name + assert "--admin_passwd=" in create and "--admin_passwd=admin" not in create # never the default + assert not any("mv " in c for c in _commands(mock_exec)) # nothing to back up + + +def test_config_warns_when_exists_without_recreate(runner): + result, mock_exec = _invoke(runner, [], {"version": "17.0"}, conf_exists=True) + + assert result.exit_code == 0 + assert "already exists" in result.output + assert not any(c.startswith("odoo-config create") for c in _commands(mock_exec)) + + +def test_config_recreate_backs_up_then_regenerates(runner): + result, mock_exec = _invoke(runner, ["--recreate"], {"version": "17.0"}, conf_exists=True) + + assert result.exit_code == 0 + cmds = _commands(mock_exec) + assert any( + c == "mv /home/deploy/odoo-myapp-staging/config/odoo.conf /home/deploy/odoo-myapp-staging/config/odoo.conf.bak" + for c in cmds + ) + assert any(c.startswith("odoo-config create") for c in cmds) + + +def test_config_defaults_version_when_undetectable(runner): + """No configured version + a missing codebase dir (local executor raises + FileNotFoundError on cwd) falls back to the prompt default instead of crashing.""" + + def capture(cmd, cwd=None, dry_run=False): + if cmd == "echo $HOME": + return "/home/deploy" + if "odoo-addons-path" in cmd: + raise FileNotFoundError(2, "No such file or directory") + return "" + + mock = _executor_mock(conf_exists=False) + mock.capture.side_effect = capture + with ( + patch("trobz_deploy.command.configure.Executor", return_value=mock), + patch("trobz_deploy.command.configure.load_config", return_value={}), + ): + result = runner.invoke(app, ["configure", "odoo-myapp-staging", "--type", "odoo", "--steps", "config"]) + + assert result.exit_code == 0 + create = next(c for c in _commands(mock) if c.startswith("odoo-config create")) + assert "--version 19.0" in create + + +def test_config_detects_version_from_addons_path(runner): + """When no version is configured, read it from `odoo-addons-path --format=json`.""" + + def capture(cmd, cwd=None, dry_run=False): + if cmd == "echo $HOME": + return "/home/deploy" + if "odoo-addons-path" in cmd: + return '{"layout": "Trobz", "version": "18.0"}' + return "" + + mock = _executor_mock(conf_exists=False) + mock.capture.side_effect = capture + with ( + patch("trobz_deploy.command.configure.Executor", return_value=mock), + patch("trobz_deploy.command.configure.load_config", return_value={}), + ): + result = runner.invoke(app, ["configure", "odoo-myapp-staging", "--type", "odoo", "--steps", "config"]) + + assert result.exit_code == 0 + create = next(c for c in _commands(mock) if c.startswith("odoo-config create")) + assert "--version 18.0" in create + + +def test_config_no_preset_for_untyped_instance(runner): + """An instance name without a known type (demo/test/...) gets no --preset.""" + with ( + patch("trobz_deploy.command.configure.Executor") as MockExecutor, + patch("trobz_deploy.command.configure.load_config", return_value={"version": "17.0"}), + ): + MockExecutor.return_value = _executor_mock(conf_exists=False) + result = runner.invoke(app, ["configure", "odoo-myapp-demo", "--type", "odoo", "--steps", "config"]) + mock_exec = MockExecutor.return_value + + assert result.exit_code == 0 + create = next(c for c in _commands(mock_exec) if c.startswith("odoo-config create")) + assert "--preset" not in create diff --git a/tests/test_configure_steps.py b/tests/test_configure_steps.py index cc5ad0e..425a6fb 100644 --- a/tests/test_configure_steps.py +++ b/tests/test_configure_steps.py @@ -17,7 +17,7 @@ def runner(): def _executor_mock(): mock = MagicMock() - def capture_side_effect(cmd, cwd=None): + def capture_side_effect(cmd, cwd=None, dry_run=False): if cmd == "echo $HOME": return "/home/deploy" return "" @@ -151,7 +151,7 @@ def test_step_ensure_postgres_role_only(runner): def test_step_all_runs_every_step_for_odoo(runner): - cfg = {"repo_url": "git@example.com:org/myapp.git"} + cfg = {"repo_url": "git@example.com:org/myapp.git", "version": "17.0"} result, mock_exec = _invoke( runner, @@ -168,6 +168,7 @@ def test_step_all_runs_every_step_for_odoo(runner): assert any(cmd.startswith("createuser") for cmd in commands) assert any("gitaggregate" in cmd for cmd in commands) assert any("odoo-venv create" in cmd for cmd in commands) + assert any("odoo-config create --version 17.0" in cmd for cmd in commands) mock_exec.write_file.assert_called_once() assert any("systemctl --user enable --now" in cmd for cmd in commands) diff --git a/trobz_deploy/command/configure.py b/trobz_deploy/command/configure.py index 9350a6a..74313e7 100644 --- a/trobz_deploy/command/configure.py +++ b/trobz_deploy/command/configure.py @@ -1,6 +1,8 @@ from __future__ import annotations +import json import secrets +import shlex from typing import Annotated, Any import typer @@ -56,9 +58,38 @@ def _ensure_postgres_user(executor: Executor, instance_name: str, dry_run: bool "pg": "Ensure postgres role exists", "gitaggregate": "Run gitaggregate if needed", "venv": "Creating the venv", + "config": "Generating Odoo config", "unit": "Installing systemd unit", } +ODOO_CONFIG_FILENAME = "odoo.conf" + +# Odoo instance types that map to an odoo-config --preset (see odoo-config overlay.toml). +# The other types (demo, hotfix, test, training) carry no preset. +INSTANCE_PRESETS = ("production", "staging", "integration") + + +def _detect_preset(instance_name: str) -> str | None: + """Derive the odoo-config --preset from the instance name (e.g. acme-staging → staging). + + Instance names are dash-separated (e.g. ``openerp-project-integration``). + """ + tokens = set(instance_name.lower().split("-")) + return next((p for p in INSTANCE_PRESETS if p in tokens), None) + + +def _detect_version(executor: Executor, service_path: str, *, dry_run: bool = False) -> str: + """Read the Odoo version from ``odoo-addons-path --format=json`` in the codebase, else prompt.""" + # OSError covers a missing service_path on the local executor (subprocess cwd= raises + # before the command runs); the remote executor degrades to a non-zero ExecutorError. + try: + out = executor.capture("odoo-addons-path -v --format=json", cwd=service_path, dry_run=dry_run) + detected = json.loads(out).get("version", "") if out else "" + except (ExecutorError, OSError, json.JSONDecodeError): + detected = "" + + return detected or typer.prompt("Target Odoo version", default="19.0") + def configure( # noqa: C901 ctx: typer.Context, @@ -81,7 +112,7 @@ def configure( # noqa: C901 recreate: Annotated[ bool, typer.Option( - help="Re-create venv in case it exists. This is useful when you want to update the venv.", + help="Re-create existing artifacts (venv, odoo-config, systemd unit) instead of skipping them.", ), ] = False, watch: Annotated[ @@ -245,7 +276,48 @@ def _run_step(slug: str) -> bool: typer.echo(typer.style(str(exc), fg="red"), err=True) raise typer.Exit(code=1) from exc - # Step 5: Install systemd unit + # Step 5: Generate Odoo config via odoo-config on the target host + if eff_type == "odoo" and _run_step("config"): + conf_dir = f"{instance_path}/config" + conf_path = f"{conf_dir}/{ODOO_CONFIG_FILENAME}" + conf_exists = _file_exists(executor, conf_path) + + if not conf_exists or recreate: + typer.secho(f"\n{CONFIGURE_STEPS['config']}…", fg="green") + version = opts.get("version") or _detect_version(executor, service_path, dry_run=dry_run) + + overrides: dict[str, Any] = { + "db_user": instance_name, + "report.url": f"http://{instance_name}:8069", + "http_interface": instance_name, + "admin_passwd": secrets.token_urlsafe(24), + } + overrides.update(opts.get("config") or {}) + override_args = " ".join(f"--{key}={shlex.quote(str(value))}" for key, value in overrides.items()) + + preset = opts.get("preset") or _detect_preset(instance_name) + preset_arg = f" --preset {shlex.quote(preset)}" if preset else "" + + try: + executor.run(f"mkdir -p {conf_dir}", dry_run=dry_run) + if conf_exists: + executor.run(f"mv {conf_path} {conf_path}.bak", dry_run=dry_run) + executor.run( + f"odoo-config create --version {shlex.quote(str(version))}{preset_arg} " + f"-c {conf_path} {override_args}", + dry_run=dry_run, + ) + except ExecutorError as exc: + typer.echo(typer.style(str(exc), fg="red"), err=True) + raise typer.Exit(code=1) from exc + + else: + typer.secho( + f"\nConfig {ODOO_CONFIG_FILENAME!r} already exists. Use --recreate to regenerate.", + fg="yellow", + ) + + # Step 6: Install systemd unit if _run_step("unit"): unit_dir = "$HOME/.config/systemd/user" unit_path = f"{unit_dir}/{instance_name}.service"