From 2a265d9cc2f1ebe7233ad0482599d10ef62aed04 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, 29 Jun 2026 12:26:23 +0700 Subject: [PATCH 1/3] feat(configure): generate config/server.env with default and override env vars New 5.2 sub-step writes config/server.env: unconditional thread-limit defaults (OMP/OPENBLAS/NUMEXPR/MKL) enriched by the deploy.yml `env` dict (user values win). Written when missing; --recreate backs up and regenerates, mirroring odoo.conf. Forge-ID: 68041 --- tests/test_configure_server_env.py | 69 ++++++++++++++++++++++++++++++ tests/test_configure_steps.py | 5 ++- trobz_deploy/command/configure.py | 42 ++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 tests/test_configure_server_env.py diff --git a/tests/test_configure_server_env.py b/tests/test_configure_server_env.py new file mode 100644 index 0000000..597b3a0 --- /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", "config"], + ) + 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..17d98c6 100644 --- a/trobz_deploy/command/configure.py +++ b/trobz_deploy/command/configure.py @@ -64,6 +64,21 @@ def _ensure_postgres_user(executor: Executor, instance_name: str, dry_run: bool 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") @@ -278,6 +293,7 @@ def _run_step(slug: str) -> bool: # Step 5: Generate Odoo config via odoo-config on the target host if eff_type == "odoo" and _run_step("config"): + # 5.1: config/odoo.conf conf_dir = f"{instance_path}/config" conf_path = f"{conf_dir}/{ODOO_CONFIG_FILENAME}" conf_exists = _file_exists(executor, conf_path) @@ -317,6 +333,32 @@ def _run_step(slug: str) -> bool: fg="yellow", ) + # 5.2: config/server.env — default thread limits, enriched by deploy.yml `env`. + env_path = f"{conf_dir}/server.env" + env_exists = _file_exists(executor, env_path) + + if not env_exists or recreate: + typer.secho("\nGenerating server.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 6: Install systemd unit if _run_step("unit"): unit_dir = "$HOME/.config/systemd/user" From 627887c2ed201ca3e7fdf7176a5a7540faada03d 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, 29 Jun 2026 14:06:29 +0700 Subject: [PATCH 2/3] docs(configuration): document the config and env deploy.yml keys Add the odoo.conf `config` overrides and the server.env `env` overrides to the schema example and options table. Forge-ID: 68041 --- site-docs/docs/configuration.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 From 4254ea8af901d16adeacff70a6bd569fcb28edb4 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, 29 Jun 2026 15:42:21 +0700 Subject: [PATCH 3/3] refactor(configure): make server.env generation its own step Move server.env out of the `config` step into a dedicated `env` step so it can be run or skipped independently. Forge-ID: 68041 --- site-docs/docs/cli-reference.md | 4 ++-- tests/test_configure_server_env.py | 2 +- trobz_deploy/command/configure.py | 10 ++++++---- 3 files changed, 9 insertions(+), 7 deletions(-) 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/tests/test_configure_server_env.py b/tests/test_configure_server_env.py index 597b3a0..c31c374 100644 --- a/tests/test_configure_server_env.py +++ b/tests/test_configure_server_env.py @@ -44,7 +44,7 @@ def _invoke(runner, cfg, *, env_exists): MockExecutor.return_value = mock_exec result = runner.invoke( app, - ["configure", "odoo-myapp-staging", "--type", "odoo", "--steps", "config"], + ["configure", "odoo-myapp-staging", "--type", "odoo", "--steps", "env"], ) return result, mock_exec diff --git a/trobz_deploy/command/configure.py b/trobz_deploy/command/configure.py index 17d98c6..85c5849 100644 --- a/trobz_deploy/command/configure.py +++ b/trobz_deploy/command/configure.py @@ -59,6 +59,7 @@ 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", } @@ -293,7 +294,6 @@ def _run_step(slug: str) -> bool: # Step 5: Generate Odoo config via odoo-config on the target host if eff_type == "odoo" and _run_step("config"): - # 5.1: config/odoo.conf conf_dir = f"{instance_path}/config" conf_path = f"{conf_dir}/{ODOO_CONFIG_FILENAME}" conf_exists = _file_exists(executor, conf_path) @@ -333,12 +333,14 @@ def _run_step(slug: str) -> bool: fg="yellow", ) - # 5.2: config/server.env — default thread limits, enriched by deploy.yml `env`. + # 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("\nGenerating server.env…", fg="green") + typer.secho(f"\n{CONFIGURE_STEPS['env']}…", fg="green") server_env = {**DEFAULT_SERVER_ENV, **(opts.get("env") or {})} try: @@ -359,7 +361,7 @@ def _run_step(slug: str) -> bool: fg="yellow", ) - # Step 6: Install systemd unit + # Step 7: Install systemd unit if _run_step("unit"): unit_dir = "$HOME/.config/systemd/user" unit_path = f"{unit_dir}/{instance_name}.service"