-
Notifications
You must be signed in to change notification settings - Fork 1
[ADD] configure: odoo-config generation step on target host #43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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), | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we add a warning for user to change password to not surprise the person about this setting?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. let's discuss about it on the private side: https://gitlab.trobz.com/packages/deploy.py-tms/-/issues/1 |
||
| } | ||
| 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" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can you generate a strong
admin_passwdby default to make sure we never haveadmin