From 3a5fe4e4fbe20bfb0eeea9b855cc1525d25065f2 Mon Sep 17 00:00:00 2001 From: Wade Williams Date: Mon, 9 Mar 2026 10:38:51 -0600 Subject: [PATCH 1/2] Support ssh port in docker compose as well --- src/flow_deploy/config.py | 4 ++++ src/flow_deploy/discovery.py | 13 +++++++--- tests/test_config.py | 45 +++++++++++++++++++++++++++++++++++ tests/test_discovery.py | 46 ++++++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 3 deletions(-) diff --git a/src/flow_deploy/config.py b/src/flow_deploy/config.py index 7edd437..da74ffb 100644 --- a/src/flow_deploy/config.py +++ b/src/flow_deploy/config.py @@ -16,6 +16,7 @@ class ServiceConfig: file_order: int host: str | None = None user: str | None = None + port: int | None = None dir: str | None = None @property @@ -65,6 +66,8 @@ def parse_services(compose_dict: dict) -> list[ServiceConfig]: # Host discovery: per-service label → x-deploy default → None host = _get_label(labels, "deploy.host") or x_deploy.get("host") user = _get_label(labels, "deploy.user") or x_deploy.get("user") + raw_port = _get_label(labels, "deploy.port") or x_deploy.get("port") + port = int(raw_port) if raw_port is not None else None svc_dir = _get_label(labels, "deploy.dir") or x_deploy.get("dir") configs.append( @@ -80,6 +83,7 @@ def parse_services(compose_dict: dict) -> list[ServiceConfig]: file_order=idx, host=host, user=user, + port=port, dir=svc_dir, ) ) diff --git a/src/flow_deploy/discovery.py b/src/flow_deploy/discovery.py index 1dbd3bd..616a722 100644 --- a/src/flow_deploy/discovery.py +++ b/src/flow_deploy/discovery.py @@ -6,12 +6,14 @@ def env_overrides() -> dict[str, str]: - """Read optional HOST_NAME / HOST_USER env-var overrides.""" + """Read optional HOST_NAME / HOST_USER / SSH_PORT env-var overrides.""" overrides: dict[str, str] = {} if os.environ.get("HOST_NAME"): overrides["host"] = os.environ["HOST_NAME"] if os.environ.get("HOST_USER"): overrides["user"] = os.environ["HOST_USER"] + if os.environ.get("SSH_PORT"): + overrides["port"] = os.environ["SSH_PORT"] return overrides @@ -33,6 +35,8 @@ def discover_hosts(compose_dict: dict, overrides: dict[str, str] | None = None) svc.host = overrides["host"] if "user" in overrides: svc.user = overrides["user"] + if "port" in overrides: + svc.port = int(overrides["port"]) missing = [s.name for s in app_services if s.host is None] if missing: @@ -40,14 +44,17 @@ def discover_hosts(compose_dict: dict, overrides: dict[str, str] | None = None) groups: dict[tuple, dict] = {} for svc in app_services: - key = (svc.host, svc.user, svc.dir) + key = (svc.host, svc.user, svc.port, svc.dir) if key not in groups: - groups[key] = { + group: dict = { "host": svc.host, "user": svc.user, "dir": svc.dir, "services": [], } + if svc.port is not None: + group["port"] = svc.port + groups[key] = group groups[key]["services"].append(svc.name) return list(groups.values()) diff --git a/tests/test_config.py b/tests/test_config.py index a9c0c86..4a42a93 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -261,6 +261,51 @@ def test_per_service_labels_override_x_deploy(): assert worker.dir == "/srv/worker" +def test_x_deploy_port(): + d = { + "x-deploy": {"host": "app-1.example.com", "user": "deploy", "port": 2222}, + "services": { + "web": { + "image": "app:latest", + "labels": {"deploy.role": "app"}, + "healthcheck": {"test": ["CMD", "true"]}, + }, + }, + } + svc = parse_services(d)[0] + assert svc.port == 2222 + + +def test_per_service_port_label_overrides_x_deploy(): + d = { + "x-deploy": {"host": "app-1.example.com", "user": "deploy", "port": 22}, + "services": { + "web": { + "image": "app:latest", + "labels": {"deploy.role": "app", "deploy.port": "2222"}, + "healthcheck": {"test": ["CMD", "true"]}, + }, + }, + } + svc = parse_services(d)[0] + assert svc.port == 2222 + + +def test_port_default_is_none(): + d = _compose_dict( + ( + "web", + { + "image": "app:latest", + "labels": {"deploy.role": "app"}, + "healthcheck": {"test": ["CMD", "true"]}, + }, + ), + ) + svc = parse_services(d)[0] + assert svc.port is None + + def test_no_x_deploy_no_labels(): d = _compose_dict( ( diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 68461dd..c9d3d9f 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -107,19 +107,65 @@ def test_missing_host_raises(): discover_hosts(d) +def test_port_from_x_deploy(): + d = _compose( + x_deploy={"host": "h1", "user": "deploy", "port": 2222, "dir": "/srv/app"}, + web=_app_svc(), + ) + groups = discover_hosts(d) + assert groups[0]["port"] == 2222 + + +def test_port_omitted_when_none(): + d = _compose( + x_deploy={"host": "h1", "user": "deploy", "dir": "/srv/app"}, + web=_app_svc(), + ) + groups = discover_hosts(d) + assert "port" not in groups[0] + + +def test_override_port(): + d = _compose( + x_deploy={"host": "h1", "user": "deploy", "dir": "/srv/app"}, + web=_app_svc(), + ) + groups = discover_hosts(d, overrides={"port": "2222"}) + assert groups[0]["port"] == 2222 + + +def test_override_port_replaces_x_deploy(): + d = _compose( + x_deploy={"host": "h1", "user": "deploy", "port": 22, "dir": "/srv/app"}, + web=_app_svc(), + ) + groups = discover_hosts(d, overrides={"port": "2222"}) + assert groups[0]["port"] == 2222 + + def test_env_overrides_reads_env(monkeypatch): monkeypatch.setenv("HOST_NAME", "env-host") monkeypatch.setenv("HOST_USER", "env-user") + monkeypatch.delenv("SSH_PORT", raising=False) assert env_overrides() == {"host": "env-host", "user": "env-user"} def test_env_overrides_empty(monkeypatch): monkeypatch.delenv("HOST_NAME", raising=False) monkeypatch.delenv("HOST_USER", raising=False) + monkeypatch.delenv("SSH_PORT", raising=False) assert env_overrides() == {} def test_env_overrides_partial(monkeypatch): monkeypatch.setenv("HOST_NAME", "env-host") monkeypatch.delenv("HOST_USER", raising=False) + monkeypatch.delenv("SSH_PORT", raising=False) assert env_overrides() == {"host": "env-host"} + + +def test_env_overrides_with_port(monkeypatch): + monkeypatch.setenv("HOST_NAME", "env-host") + monkeypatch.setenv("HOST_USER", "env-user") + monkeypatch.setenv("SSH_PORT", "2222") + assert env_overrides() == {"host": "env-host", "user": "env-user", "port": "2222"} From 480153a300470431e5879efb0f71acaee5323574 Mon Sep 17 00:00:00 2001 From: Wade Williams Date: Mon, 9 Mar 2026 10:43:53 -0600 Subject: [PATCH 2/2] update docs --- SPEC.md | 12 ++++++++---- docs/github-actions.md | 9 +++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/SPEC.md b/SPEC.md index 1e6205d..54071d7 100644 --- a/SPEC.md +++ b/SPEC.md @@ -91,6 +91,7 @@ Host information is declared in the compose file using Docker Compose's native ` x-deploy: host: app-1.example.com user: deploy + port: 22 dir: /srv/myapp services: @@ -114,7 +115,7 @@ services: deploy.role: accessory ``` -All services inherit `host`, `user`, and `dir` from `x-deploy`. One declaration, no repetition. +All services inherit `host`, `user`, `port`, and `dir` from `x-deploy`. One declaration, no repetition. **Multi-host:** Per-service labels override the defaults: @@ -155,12 +156,13 @@ services: |---|---|---| | `deploy.host` | `x-deploy.host` | SSH hostname for this service | | `deploy.user` | `x-deploy.user` | SSH user for this service | +| `deploy.port` | `x-deploy.port` | SSH port for this service | | `deploy.dir` | `x-deploy.dir` | Project directory on the remote host | **Resolution order** (highest priority wins): 1. GitHub Actions environment variable (`HOST_NAME`, `HOST_USER`, `SSH_PORT`) -2. Per-service label (`deploy.host`, `deploy.user`, `deploy.dir`) +2. Per-service label (`deploy.host`, `deploy.user`, `deploy.port`, `deploy.dir`) 3. Top-level `x-deploy` default 4. Error if none is set (for `host`) @@ -170,7 +172,7 @@ Environment variable overrides exist because `x-deploy` values are committed to |---|---|---| | `HOST_NAME` | `x-deploy.host` / `deploy.host` | SSH hostname for all services | | `HOST_USER` | `x-deploy.user` / `deploy.user` | SSH user for all services | -| `SSH_PORT` | *(default 22)* | SSH port for all connections | +| `SSH_PORT` | `deploy.port` / `x-deploy.port` | SSH port for all connections | The GitHub Action (§7) reads these values by running ` config` in CI, which outputs the fully merged YAML with all overrides applied. It then groups services by host, applies any environment variable overrides, and SSHes to each one. @@ -579,6 +581,7 @@ The tool has zero configuration files. All behavior is controlled by `x-deploy` |---|---|---| | `x-deploy.host` | No* | Default SSH hostname | | `x-deploy.user` | No* | Default SSH user | +| `x-deploy.port` | No | Default SSH port | | `x-deploy.dir` | No | Default project directory on remote host | \* Required unless overridden by an environment variable or per-service label. @@ -590,6 +593,7 @@ The tool has zero configuration files. All behavior is controlled by `x-deploy` | `deploy.role` | Yes | *(none)* | `app` or `accessory` | | `deploy.host` | No | `x-deploy.host` | SSH hostname for this service | | `deploy.user` | No | `x-deploy.user` | SSH user for this service | +| `deploy.port` | No | `x-deploy.port` | SSH port for this service | | `deploy.dir` | No | `x-deploy.dir` | Project directory on remote host | | `deploy.order` | No | `100` | Deploy order (lower first) | | `deploy.drain` | No | `30` | Seconds to wait after SIGTERM before SIGKILL | @@ -602,7 +606,7 @@ The tool has zero configuration files. All behavior is controlled by `x-deploy` |---|---|---| | `HOST_NAME` | `deploy.host` / `x-deploy.host` | SSH hostname for all services | | `HOST_USER` | `deploy.user` / `x-deploy.user` | SSH user for all services | -| `SSH_PORT` | *(default 22)* | SSH port for all connections | +| `SSH_PORT` | `deploy.port` / `x-deploy.port` | SSH port for all connections | These are useful for public repositories where hostnames and usernames should not be committed to version control. diff --git a/docs/github-actions.md b/docs/github-actions.md index ce51947..bdc29bf 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -162,7 +162,7 @@ jobs: For each host group discovered from your compose config: 1. **SSH agent** — loads your deploy key -2. **Discover hosts** — parses `docker-compose.yml` for `x-deploy` and `deploy.*` labels, groups services by `(host, user, dir)` +2. **Discover hosts** — parses `docker-compose.yml` for `x-deploy` and `deploy.*` labels, groups services by `(host, user, port, dir)` 3. **GHCR login** — authenticates Docker on the server (and logs out after) 4. **Git pull** — fast-forward only, fails safely if the server has diverged 5. **Deploy** — runs `flow-deploy deploy --tag ` on the server @@ -175,13 +175,14 @@ The action discovers where to deploy from your compose file: x-deploy: host: app-1.example.com user: deploy + port: 22 dir: /srv/myapp ``` **Priority order** (highest wins): -1. GitHub Actions variables (`vars.HOST_NAME`, `vars.HOST_USER`) -2. Per-service labels (`deploy.host`, `deploy.user`, `deploy.dir`) +1. GitHub Actions variables (`vars.HOST_NAME`, `vars.HOST_USER`, `vars.SSH_PORT`) +2. Per-service labels (`deploy.host`, `deploy.user`, `deploy.port`, `deploy.dir`) 3. `x-deploy` top-level defaults This means you can keep `x-deploy` in your compose file for local/development use and override with GitHub variables for production — keeping real hostnames out of version control. @@ -208,7 +209,7 @@ services: deploy.dir: /srv/worker ``` -The action groups services by `(host, user, dir)` and deploys to each group sequentially. +The action groups services by `(host, user, port, dir)` and deploys to each group sequentially. ## Versioned Releases