Skip to content
Open
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
92 changes: 86 additions & 6 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ deploy [--config FILE] configure <instance_name> [<ssh_host>] [<repo_url>] [--ty
| Argument | Required without config | Description |
|----------------|------------------------|------------------------------------------------------------------------------|
| `instance_name` | Always | Logical name for the instance (used for paths and service name) |
| `ssh_host` | If not in config | SSH target, or `localhost` / omit to deploy locally without SSH |
| `ssh_host` | If not in config | SSH target, or `localhost` / omit to deploy locally without SSH. In `deploy.yml` only, may instead be a list of hosts — see **Multiple Hosts** below |
| `repo_url` | If not in config | Git repository URL (e.g. `git@github.com:org/repo.git`) |

**Options**
Expand All @@ -122,7 +122,9 @@ Steps 2–6 each correspond to a `--steps` / `--except` slug, shown in **bold**.
(connecting) always runs regardless of `--steps`/`--except`.

1. **Connect** — if `ssh_host` is set and is not `localhost`, open an SSH connection.
Otherwise all subsequent commands run as local subprocesses.
Otherwise all subsequent commands run as local subprocesses. If `ssh_host` is a list
(`deploy.yml` only), steps 1 onward repeat for each host in list order — see
**Multiple Hosts** below.

2. **`set-up-instance-dir`** — set up `~/<instance_name>` on the target host:
- **`python` with `requirements`** (package mode): create the directory with `mkdir -p`.
Expand Down Expand Up @@ -237,7 +239,7 @@ deploy [--config FILE] update <instance_name> [<ssh_host>] [-p <ssh_port>] [--ty
| Argument | Required without config | Description |
|----------------|------------------------|------------------------------------------------------------------------------|
| `instance_name` | Always | Name of the previously configured instance |
| `ssh_host` | If not in config | SSH target, or `localhost` / omit to deploy locally without SSH |
| `ssh_host` | If not in config | SSH target, or `localhost` / omit to deploy locally without SSH. In `deploy.yml` only, may instead be a list of hosts — see **Multiple Hosts** below |

**Options**

Expand Down Expand Up @@ -302,7 +304,9 @@ Steps 5–7 each correspond to a `--steps` / `--except` slug, shown in **bold**.
and 8–9 always run regardless of `--steps`/`--except`.

1. **Connect** — if `ssh_host` is set and is not `localhost`, open an SSH connection.
Otherwise all subsequent commands run as local subprocesses.
Otherwise all subsequent commands run as local subprocesses. If `ssh_host` is a list
(`deploy.yml` only), steps 1 onward repeat for each host in list order — see
**Multiple Hosts** below.

2. **Run `pre-update` hooks** — execute all `pre-update` commands in order (non-blocking).

Expand Down Expand Up @@ -397,7 +401,7 @@ deploy [--config FILE] status <instance_name> [<ssh_host>] [-p <ssh_port>]
| Argument | Required without config | Description |
|----------------|------------------------|------------------------------------------------------------------------------|
| `instance_name` | Always | Name of the previously configured instance |
| `ssh_host` | If not in config | SSH target, or `localhost` / omit to deploy locally without SSH |
| `ssh_host` | If not in config | SSH target, or `localhost` / omit to deploy locally without SSH. In `deploy.yml` only, may instead be a list of hosts — see **Multiple Hosts** below |
| `ssh_port` | If not in config | SSH port, default 22 |

**Output**
Expand All @@ -411,10 +415,17 @@ Branch: main (abc1234)
Unit: active (running) since 2026-03-09 08:12:03
```

When `ssh_host` resolves to more than one host (`deploy.yml` list form with 2+ entries), this
block is printed once per host, in list order, each preceded by a `Host: <hostname>` line and
separated from the next by a blank line. A single-entry list is treated like a plain string and
prints the block above with no `Host:` line.

**Steps (executed in order)**

1. **Connect** — if `ssh_host` is set and is not `localhost`, open an SSH connection.
Otherwise all subsequent commands run as local subprocesses.
Otherwise all subsequent commands run as local subprocesses. If `ssh_host` is a list
(`deploy.yml` only), steps 1 onward repeat for each host in list order — see
**Multiple Hosts** below.

2. **Git info** — inside `~/<instance_name>`, run:
- `git remote get-url origin` → remote URL
Expand Down Expand Up @@ -485,6 +496,75 @@ The config file is resolved **locally** (on the machine running `deploy`), not o
It is **not** committed to the project repository — it lives outside the deployment folder, typically
alongside the operator's other deployment scripts or in a private configuration repository.

### Multiple Hosts (`ssh_host` as a list)

In `deploy.yml`, `ssh_host` may be a list instead of a single string, so that `configure`,
`update`, and `status` can each be rolled out across several hosts under one `instance_name`.
The CLI positional `<ssh_host>` argument always takes a single value — list form is only
available via the config file.

```yaml
# deploy.yml
openerp-myproject-production:
repo_url: git@github.com:org/repo.git
db: myproject_production
ssh_host:
- host2.example.com:
watch: true
steps:
update: pull, venv, db
- host1.example.com:
watch: true
steps:
update: pull, venv
ssh_port: 22
ssh_user: openerp
```

- Each list entry is a single-key mapping: the key is the hostname, the value is an optional
mapping of per-host overrides (`watch`, `steps`). A host with no overrides can be written as a
bare string instead of a mapping.
- `ssh_user` is the SSH user shared by every bare hostname in the list, so individual entries
don't need to repeat `user@host`. An entry that already embeds a user (`user@host`) ignores
`ssh_user`. `ssh_user` is ignored when `ssh_host` is a single string — embed the user in that
string instead, as today.
- `ssh_port` and every other instance-level setting (`type`, `db`, `hooks`, …) stay shared across
all hosts in the list; there is no per-host override for them.

**Per-host `steps`** — keyed by command name (`configure` or `update`; `status` has no
`--steps`/`--except` option and ignores this key). The value uses the same comma-separated slugs
as that command's `--steps`/`--except` (e.g. `pull`, `venv`, `db` for `update`; see each
command's **Steps** section). It becomes that host's effective `--steps` for the matching
command, with no `--except` for that host. `--steps`/`--except` passed on the CLI still wins
over it for every host, per the usual CLI > config > default precedence — a host's `steps` entry
only takes effect when the CLI was left at its defaults (no `--steps`/`--except`). A host with no
entry for the current command name runs with the default (`all`).

**Per-host `watch`** — same meaning as the `--watch` flag, scoped to that host. The CLI
`--watch` flag, when passed, forces every host to be watched, overriding any per-host
`watch: false`.

**Execution order** — hosts are processed **sequentially, in list order**: `deploy` connects to
the first host, runs the selected steps/hooks to completion, then moves to the next. If a host
fails, the command aborts immediately with that host's exit code — later hosts are not attempted.
This lets one host own a step that must not run twice (e.g. `db` in the example above only runs
on `host2.example.com`; `host1.example.com` only refreshes code and the venv), and ensures the
database has migrated before other hosts pick up the new code that depends on it. For `configure`
and `update`, when there is more than one host, each host's turn is announced with a banner before
its steps run:

```
=== Host 1/2: host2.example.com ===
```

A single-entry list runs exactly like a plain string — no banner, no `Host:` line, identical
output to today.

**Watching multiple hosts** — when more than one host ends up with `watch` enabled (via the CLI
flag or per-host config), their `journalctl` streams (and merged odoo / click-odoo-update logs
where applicable) are interleaved as they arrive and each line is prefixed with a colored
`[hostname]` label, so output from concurrently-tailed hosts stays distinguishable.

---

## Bundled Templates
Expand Down
130 changes: 130 additions & 0 deletions tests/test_config_multi_host.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
from __future__ import annotations

import pytest

from trobz_deploy.utils.config import normalize_hosts, resolve_host_steps

# ---------------------------------------------------------------------------
# normalize_hosts
# ---------------------------------------------------------------------------


def test_none_host_becomes_single_entry():
assert normalize_hosts(None) == [{"host": None, "watch": None, "steps": {}}]


def test_single_string_host_is_untouched_by_ssh_user():
# ssh_user only applies to list entries; a plain string keeps any embedded user as-is.
assert normalize_hosts("deploy@myserver.example.com", ssh_user="openerp") == [
{"host": "deploy@myserver.example.com", "watch": None, "steps": {}}
]


def test_list_of_bare_strings_has_no_overrides():
assert normalize_hosts(["host1.example.com", "host2.example.com"]) == [
{"host": "host1.example.com", "watch": None, "steps": {}},
{"host": "host2.example.com", "watch": None, "steps": {}},
]


def test_list_entry_with_overrides_extracts_watch_and_steps():
raw = [
{
"host2.example.com": {
"watch": True,
"steps": {"update": "pull, venv, db"},
}
},
{"host1.example.com": {"watch": True, "steps": {"update": "pull, venv"}}},
]

result = normalize_hosts(raw)

assert result == [
{"host": "host2.example.com", "watch": True, "steps": {"update": "pull, venv, db"}},
{"host": "host1.example.com", "watch": True, "steps": {"update": "pull, venv"}},
]


def test_list_entry_with_null_overrides_is_equivalent_to_bare_string():
assert normalize_hosts([{"host1.example.com": None}]) == [{"host": "host1.example.com", "watch": None, "steps": {}}]


def test_ssh_user_prefixes_bare_hostnames_in_list_form():
result = normalize_hosts(["host1.example.com"], ssh_user="openerp")

assert result == [{"host": "openerp@host1.example.com", "watch": None, "steps": {}}]


def test_ssh_user_does_not_override_embedded_user():
result = normalize_hosts(["deploy@host1.example.com"], ssh_user="openerp")

assert result == [{"host": "deploy@host1.example.com", "watch": None, "steps": {}}]


def test_ssh_user_does_not_apply_to_localhost():
result = normalize_hosts(["localhost"], ssh_user="openerp")

assert result == [{"host": "localhost", "watch": None, "steps": {}}]


def test_invalid_list_entry_type_raises():
with pytest.raises(ValueError, match="Invalid ssh_host list entry"):
normalize_hosts([123])


def test_multi_key_mapping_entry_raises():
with pytest.raises(ValueError, match="Invalid ssh_host list entry"):
normalize_hosts([{"host1.example.com": {}, "host2.example.com": {}}])


def test_non_mapping_overrides_raises():
with pytest.raises(ValueError, match="Invalid ssh_host overrides"):
normalize_hosts([{"host1.example.com": "not-a-mapping"}])


# ---------------------------------------------------------------------------
# resolve_host_steps
# ---------------------------------------------------------------------------

STEPS = {"pull": "Pull", "venv": "Venv", "db": "DB"}


def test_no_host_override_keeps_cli_resolved_steps():
steps, skip = resolve_host_steps({}, "update", ["all"], [], STEPS)

assert steps == ["all"]
assert skip == []


def test_host_override_applies_when_cli_did_not_constrain():
steps, skip = resolve_host_steps({"update": "pull, venv"}, "update", ["all"], [], STEPS)

assert steps == ["pull", "venv"]
assert skip == []


def test_host_override_for_other_action_is_ignored():
steps, skip = resolve_host_steps({"configure": "dir"}, "update", ["all"], [], STEPS)

assert steps == ["all"]
assert skip == []


def test_cli_steps_override_wins_over_host_override():
steps, skip = resolve_host_steps({"update": "pull, venv"}, "update", ["db"], [], STEPS)

assert steps == ["db"]
assert skip == []


def test_cli_except_override_wins_over_host_override():
steps, skip = resolve_host_steps({"update": "pull, venv"}, "update", ["all"], ["db"], STEPS)

assert steps == ["all"]
assert skip == ["db"]


def test_invalid_host_step_slug_raises():
with pytest.raises(ValueError, match="Invalid ssh_host steps\\.update"):
resolve_host_steps({"update": "bogus"}, "update", ["all"], [], STEPS)
103 changes: 103 additions & 0 deletions tests/test_configure_multi_host.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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():
mock = MagicMock()

def capture_side_effect(cmd, cwd=None):
if cmd == "echo $HOME":
return "/home/deploy"
return ""

mock.capture.side_effect = capture_side_effect
return mock


def _invoke(runner, instance_name, deploy_type, extra_args, cfg, mocks):
with (
patch("trobz_deploy.command.configure.Executor") as MockExecutor,
patch("trobz_deploy.command.configure.load_config", return_value=cfg),
patch("trobz_deploy.command.configure.render_unit", return_value="[Unit]\n"),
):
MockExecutor.side_effect = mocks
result = runner.invoke(app, ["configure", instance_name, "--type", deploy_type, *extra_args])
return result, MockExecutor


def test_hosts_are_visited_in_order_with_per_host_steps(runner):
cfg = {
"exec_start": "/usr/bin/myapp",
"ssh_host": [
{"host2.example.com": {"steps": {"configure": "dir"}}},
{"host1.example.com": {"steps": {"configure": "unit"}}},
],
}
mock1, mock2 = _executor_mock(), _executor_mock()

result, MockExecutor = _invoke(runner, "service-myapp-production", "service", [], cfg, [mock1, mock2])

assert result.exit_code == 0
assert MockExecutor.call_args_list[0].args[0] == "host2.example.com"
assert MockExecutor.call_args_list[1].args[0] == "host1.example.com"
assert "=== Host 1/2: host2.example.com ===" in result.output
assert "=== Host 2/2: host1.example.com ===" in result.output

commands1 = [c.args[0] for c in mock1.run.call_args_list]
commands2 = [c.args[0] for c in mock2.run.call_args_list]
assert any("mkdir -p" in c for c in commands1)
assert not any("mkdir -p" in c for c in commands2)


def test_aborts_immediately_on_first_host_failure(runner):
cfg = {"repo_url": "git@example.com:org/myapp.git", "ssh_host": ["host1.example.com", "host2.example.com"]}
mock1 = _executor_mock()

def run_side_effect(cmd, cwd=None, check=True, dry_run=False):
if cmd.startswith("test -d") or cmd.startswith("test -f"):
msg = "not found"
raise ExecutorError(msg)
if cmd.startswith("git clone"):
msg = "network unreachable"
raise ExecutorError(msg)
return ""

mock1.run.side_effect = run_side_effect
mock2 = _executor_mock()

result, MockExecutor = _invoke(runner, "odoo-myapp-staging", "odoo", [], cfg, [mock1, mock2])

assert result.exit_code == 1
assert MockExecutor.call_count == 1


def test_multiple_watched_hosts_dispatch_to_watch_logs_multi(runner):
cfg = {
"exec_start": "/usr/bin/myapp",
"ssh_host": [
{"host2.example.com": {"watch": True}},
{"host1.example.com": {"watch": True}},
],
}
mock1, mock2 = _executor_mock(), _executor_mock()

with patch("trobz_deploy.command.configure.watch_logs_multi") as mock_watch_multi:
result, _ = _invoke(runner, "service-myapp-production", "service", [], cfg, [mock1, mock2])

assert result.exit_code == 0
mock_watch_multi.assert_called_once_with([
(mock1, "service", "service-myapp-production"),
(mock2, "service", "service-myapp-production"),
])
Loading