diff --git a/site-docs/docs/cli-reference.md b/site-docs/docs/cli-reference.md index fe7ce3f..09d7beb 100644 --- a/site-docs/docs/cli-reference.md +++ b/site-docs/docs/cli-reference.md @@ -60,8 +60,8 @@ $ deploy configure [OPTIONS] INSTANCE_NAME [SSH_HOST] [REPO_URL] * `--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, config, unit. \[default: all\] -* `--except TEXT`: Comma-separated steps to skip. Available: dir, pg, gitaggregate, venv, config, unit. +* `--steps TEXT`: Comma-separated steps to run, or 'all'. Available: dir, pg, gitaggregate, venv, config, env, unit. \[default: all\] +* `--except TEXT`: Comma-separated steps to skip. Available: dir, pg, gitaggregate, venv, config, env, unit. * `--dry-run`: Go through all steps without running any writing/destructive commands. * `--help`: Show this message and exit. diff --git a/site-docs/docs/configuration.md b/site-docs/docs/configuration.md index 34e83da..72bcea1 100644 --- a/site-docs/docs/configuration.md +++ b/site-docs/docs/configuration.md @@ -41,6 +41,17 @@ odoo-myproject-production: - myproject_staging - myproject_integration + # Odoo config overrides — written to config/odoo.conf by `configure` + config: + workers: 4 + limit_time_cpu: 600 + + # Environment variables — written to config/server.env by `configure`. + # Merged over the built-in thread-limit defaults (the value here wins). + env: + ODOO_SESSION_REDIS: 1 + ODOO_SESSION_REDIS_HOST: localhost + # python / service only exec_start: myapp.main:app # module path for python; verbatim for service build: npm ci && npm run build # service only @@ -74,6 +85,8 @@ odoo-myproject-production: | `db` | string or list of string | `update` | Target database name (Odoo only). Can be a list for multiple names. | | `exec_start` | string | `configure` | Entry point for python/service systemd unit. | | `build` | string | `configure`, `update` | Build command for `service` type. | +| `config` | mapping | `configure` | Odoo config overrides written to `config/odoo.conf` (Odoo only). | +| `env` | mapping | `configure` | Environment variables written to `config/server.env`, merged over the built-in thread-limit defaults (Odoo only). | | `hooks` | mapping | `update` | Lifecycle hooks — see [Hooks](hooks.md). | ## Multiple instances diff --git a/tests/test_configure_server_env.py b/tests/test_configure_server_env.py new file mode 100644 index 0000000..c31c374 --- /dev/null +++ b/tests/test_configure_server_env.py @@ -0,0 +1,69 @@ +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.command.configure import _render_server_env +from trobz_deploy.utils.executor import ExecutorError + + +@pytest.fixture +def runner(): + return CliRunner() + + +def test_render_server_env_emits_key_value_lines(): + rendered = _render_server_env({"A": 1, "B": "x"}) + assert rendered == "A=1\nB=x\n" + + +def _executor_mock(*, env_exists: bool): + """Executor mock where config/server.env 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 env_exists and cmd.startswith("test -f") and cmd.endswith("server.env"): + msg = "not found" + raise ExecutorError(msg) + return "" + + mock.run.side_effect = run_side_effect + return mock + + +def _invoke(runner, cfg, *, env_exists): + with ( + patch("trobz_deploy.command.configure.Executor") as MockExecutor, + patch("trobz_deploy.command.configure.load_config", return_value=cfg), + ): + mock_exec = _executor_mock(env_exists=env_exists) + MockExecutor.return_value = mock_exec + result = runner.invoke( + app, + ["configure", "odoo-myapp-staging", "--type", "odoo", "--steps", "env"], + ) + return result, mock_exec + + +def test_server_env_writes_defaults_plus_overrides(runner): + cfg = {"version": "17.0", "env": {"ODOO_SESSION_REDIS": 1, "OMP_NUM_THREADS": 4}} + result, mock_exec = _invoke(runner, cfg, env_exists=False) + + assert result.exit_code == 0 + written = mock_exec.write_file.call_args.args[0] + assert "OPENBLAS_NUM_THREADS=1\n" in written # default kept + assert "ODOO_SESSION_REDIS=1\n" in written # extra env added + assert "OMP_NUM_THREADS=4\n" in written # user value wins over default + assert "OMP_NUM_THREADS=1\n" not in written + + +def test_server_env_skips_when_exists_without_recreate(runner): + result, mock_exec = _invoke(runner, {"version": "17.0"}, env_exists=True) + + assert result.exit_code == 0 + assert "server.env already exists" in result.output + mock_exec.write_file.assert_not_called() diff --git a/tests/test_configure_steps.py b/tests/test_configure_steps.py index 425a6fb..27cc304 100644 --- a/tests/test_configure_steps.py +++ b/tests/test_configure_steps.py @@ -169,7 +169,10 @@ def test_step_all_runs_every_step_for_odoo(runner): 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() + # Two file writes: config/server.env and the systemd unit. + written = [call.args[0] for call in mock_exec.write_file.call_args_list] + assert mock_exec.write_file.call_count == 2 + assert any("OMP_NUM_THREADS=1" in content for content in written) 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 74313e7..85c5849 100644 --- a/trobz_deploy/command/configure.py +++ b/trobz_deploy/command/configure.py @@ -59,11 +59,27 @@ def _ensure_postgres_user(executor: Executor, instance_name: str, dry_run: bool "gitaggregate": "Run gitaggregate if needed", "venv": "Creating the venv", "config": "Generating Odoo config", + "env": "Generating server.env", "unit": "Installing systemd unit", } ODOO_CONFIG_FILENAME = "odoo.conf" +# Default env vars written to config/server.env for every odoo instance. +# Single-thread the BLAS/OpenMP backends so workers don't oversubscribe cores. +DEFAULT_SERVER_ENV: dict[str, Any] = { + "OMP_NUM_THREADS": 1, + "OPENBLAS_NUM_THREADS": 1, + "NUMEXPR_NUM_THREADS": 1, + "MKL_NUM_THREADS": 1, +} + + +def _render_server_env(env: dict[str, Any]) -> str: + """Render an ``env`` dict as ``KEY=value`` lines for config/server.env.""" + return "".join(f"{key}={value}\n" for key, value in env.items()) + + # 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") @@ -317,7 +333,35 @@ def _run_step(slug: str) -> bool: fg="yellow", ) - # Step 6: Install systemd unit + # Step 6: Generate config/server.env — default thread limits, enriched by deploy.yml `env`. + if eff_type == "odoo" and _run_step("env"): + conf_dir = f"{instance_path}/config" + env_path = f"{conf_dir}/server.env" + env_exists = _file_exists(executor, env_path) + + if not env_exists or recreate: + typer.secho(f"\n{CONFIGURE_STEPS['env']}…", fg="green") + server_env = {**DEFAULT_SERVER_ENV, **(opts.get("env") or {})} + + try: + executor.run(f"mkdir -p {conf_dir}", dry_run=dry_run) + + if env_exists: + executor.run(f"mv {env_path} {env_path}.bak", dry_run=dry_run) + + executor.write_file(_render_server_env(server_env), env_path, 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( + "\nserver.env already exists. Use --recreate to regenerate.", + fg="yellow", + ) + + # Step 7: Install systemd unit if _run_step("unit"): unit_dir = "$HOME/.config/systemd/user" unit_path = f"{unit_dir}/{instance_name}.service"