From 85ee581a4706019f3cfabf75045f90412e6a765b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C5=A9ng=20=28Tr=E1=BA=A7n=20=C4=90=C3=ACnh=29?= Date: Fri, 19 Jun 2026 14:42:07 +0700 Subject: [PATCH 1/2] [ADD] configure: odoo-config generation step on target host New "config" step in `deploy configure` (odoo only): generate config/odoo.conf by running odoo-config on the target host, mirroring the "unit" step (warn if it exists, regenerate on --recreate after backing up to .bak). Defaults passed as overrides: db_user, report.url, http_interface; the deploy.yml `config:` block overrides them. Target Odoo version comes from the `version:` key, else prompts (default 19.0). Forge-ID: 68011 --- site-docs/docs/cli-reference.md | 6 ++- tests/test_configure_config.py | 90 +++++++++++++++++++++++++++++++ tests/test_configure_steps.py | 3 +- trobz_deploy/command/configure.py | 44 ++++++++++++++- 4 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 tests/test_configure_config.py diff --git a/site-docs/docs/cli-reference.md b/site-docs/docs/cli-reference.md index 3eedc0c..fb08ecc 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. @@ -140,3 +141,4 @@ $ deploy status [OPTIONS] INSTANCE_NAME [SSH_HOST] * `-p, --port INTEGER`: SSH port on the remote host. * `--watch`: Stream service logs with journalctl after showing status. Also merge with odoo and click-odoo-update logs if applicable. * `--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..881da31 --- /dev/null +++ b/tests/test_configure_config.py @@ -0,0 +1,90 @@ +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: "/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 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_unset(runner): + result, mock_exec = _invoke(runner, [], {}, 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 19.0" in create diff --git a/tests/test_configure_steps.py b/tests/test_configure_steps.py index cc5ad0e..cea49fd 100644 --- a/tests/test_configure_steps.py +++ b/tests/test_configure_steps.py @@ -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..5ce7708 100644 --- a/trobz_deploy/command/configure.py +++ b/trobz_deploy/command/configure.py @@ -1,6 +1,7 @@ from __future__ import annotations import secrets +import shlex from typing import Annotated, Any import typer @@ -56,9 +57,12 @@ 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" + def configure( # noqa: C901 ctx: typer.Context, @@ -81,7 +85,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 +249,43 @@ 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 typer.prompt("Target Odoo version", default="19.0") + + overrides: dict[str, Any] = { + "db_user": instance_name, + "report.url": f"http://{instance_name}:8069", + "http_interface": instance_name, + } + overrides.update(opts.get("config") or {}) + override_args = " ".join(f"--{key}={shlex.quote(str(value))}" for key, value in overrides.items()) + + 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))} -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" From b34b3d25e142fe2f2676c05d32d2cac955d98f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=C5=A9ng=20=28Tr=E1=BA=A7n=20=C4=90=C3=ACnh=29?= Date: Mon, 22 Jun 2026 09:49:33 +0700 Subject: [PATCH 2/2] =?UTF-8?q?[IMP]=20configure:=20address=20review=20?= =?UTF-8?q?=E2=80=94=20preset,=20version=20detect,=20admin=5Fpasswd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Derive odoo-config --preset from the dash-separated instance type (production/staging/integration); demo/hotfix/test/training get none. Override via the deploy.yml `preset:` key. - Detect the Odoo version from `odoo-addons-path --verbose` in the codebase instead of prompting; prompt only as a fallback. - Generate a strong admin_passwd by default so the config never ships `admin`. Forge-ID: 68011 --- site-docs/docs/cli-reference.md | 1 - tests/test_configure_config.py | 64 +++++++++++++++++++++++++++++-- tests/test_configure_steps.py | 2 +- trobz_deploy/command/configure.py | 36 ++++++++++++++++- 4 files changed, 95 insertions(+), 8 deletions(-) diff --git a/site-docs/docs/cli-reference.md b/site-docs/docs/cli-reference.md index fb08ecc..fe7ce3f 100644 --- a/site-docs/docs/cli-reference.md +++ b/site-docs/docs/cli-reference.md @@ -141,4 +141,3 @@ $ deploy status [OPTIONS] INSTANCE_NAME [SSH_HOST] * `-p, --port INTEGER`: SSH port on the remote host. * `--watch`: Stream service logs with journalctl after showing status. Also merge with odoo and click-odoo-update logs if applicable. * `--help`: Show this message and exit. - diff --git a/tests/test_configure_config.py b/tests/test_configure_config.py index 881da31..416ddd0 100644 --- a/tests/test_configure_config.py +++ b/tests/test_configure_config.py @@ -17,7 +17,7 @@ def runner(): 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: "/home/deploy" if cmd == "echo $HOME" else "" + 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"): @@ -59,6 +59,8 @@ def test_config_generates_with_defaults_and_user_overrides(runner): 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 @@ -82,9 +84,63 @@ def test_config_recreate_backs_up_then_regenerates(runner): assert any(c.startswith("odoo-config create") for c in cmds) -def test_config_defaults_version_when_unset(runner): - result, mock_exec = _invoke(runner, [], {}, conf_exists=False) +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_exec) if c.startswith("odoo-config create")) + 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 cea49fd..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 "" diff --git a/trobz_deploy/command/configure.py b/trobz_deploy/command/configure.py index 5ce7708..74313e7 100644 --- a/trobz_deploy/command/configure.py +++ b/trobz_deploy/command/configure.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import secrets import shlex from typing import Annotated, Any @@ -63,6 +64,32 @@ def _ensure_postgres_user(executor: Executor, instance_name: str, dry_run: bool 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, @@ -257,22 +284,27 @@ def _run_step(slug: str) -> bool: if not conf_exists or recreate: typer.secho(f"\n{CONFIGURE_STEPS['config']}…", fg="green") - version = opts.get("version") or typer.prompt("Target Odoo version", default="19.0") + 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))} -c {conf_path} {override_args}", + 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: