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
12 changes: 8 additions & 4 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:

Expand Down Expand Up @@ -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`)

Expand All @@ -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 `<compose-command> 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.

Expand Down Expand Up @@ -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.
Expand All @@ -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 |
Expand All @@ -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.

Expand Down
9 changes: 5 additions & 4 deletions docs/github-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tag>` on the server
Expand All @@ -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.
Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions src/flow_deploy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -80,6 +83,7 @@ def parse_services(compose_dict: dict) -> list[ServiceConfig]:
file_order=idx,
host=host,
user=user,
port=port,
dir=svc_dir,
)
)
Expand Down
13 changes: 10 additions & 3 deletions src/flow_deploy/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -33,21 +35,26 @@ 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:
raise ValueError(f"services missing deploy host: {', '.join(missing)}")

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())
45 changes: 45 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
(
Expand Down
46 changes: 46 additions & 0 deletions tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}