Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions site-docs/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
146 changes: 146 additions & 0 deletions tests/test_configure_config.py
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
5 changes: 3 additions & 2 deletions tests/test_configure_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down Expand Up @@ -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,
Expand All @@ -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)

Expand Down
76 changes: 74 additions & 2 deletions trobz_deploy/command/configure.py
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
Expand Down Expand Up @@ -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,
Expand All @@ -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[
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown
Contributor

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_passwd by default to make sure we never have admin

"admin_passwd": secrets.token_urlsafe(24),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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"
Expand Down