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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ services:
retries: 5
```

Every `deploy.role=app` service **must** have a `healthcheck`. Services without a `deploy.role` label are ignored.
Every `deploy.role=app` service **must** have a `healthcheck` defined in compose (Dockerfile `HEALTHCHECK` is not detected). To opt out, set `deploy.healthcheck.skip=true`. Services without a `deploy.role` label are ignored.

**2. Deploy:**

Expand Down Expand Up @@ -88,6 +88,7 @@ All configuration is via Docker labels on your services:
| `deploy.drain` | `30` | Seconds to wait for graceful shutdown |
| `deploy.healthcheck.timeout` | `120` | Seconds to wait for healthy |
| `deploy.healthcheck.poll` | `2` | Seconds between health polls |
| `deploy.healthcheck.skip` | `false` | Skip healthcheck validation and waiting |

## Host Discovery

Expand Down
16 changes: 14 additions & 2 deletions src/flow_deploy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class ServiceConfig:
healthcheck_timeout: int
healthcheck_poll: int
has_healthcheck: bool
healthcheck_skip: bool
file_order: int
host: str | None = None
user: str | None = None
Expand Down Expand Up @@ -62,6 +63,11 @@ def parse_services(compose_dict: dict) -> list[ServiceConfig]:
continue

has_healthcheck = "healthcheck" in svc and svc["healthcheck"].get("test") is not None
healthcheck_skip = _get_label(labels, "deploy.healthcheck.skip", "").lower() in (
"true",
"1",
"yes",
)

# Host discovery: per-service label → x-deploy default → None
host = _get_label(labels, "deploy.host") or x_deploy.get("host")
Expand All @@ -80,6 +86,7 @@ def parse_services(compose_dict: dict) -> list[ServiceConfig]:
healthcheck_timeout=int(_get_label(labels, "deploy.healthcheck.timeout", "120")),
healthcheck_poll=int(_get_label(labels, "deploy.healthcheck.poll", "2")),
has_healthcheck=has_healthcheck,
healthcheck_skip=healthcheck_skip,
file_order=idx,
host=host,
user=user,
Expand All @@ -93,5 +100,10 @@ def parse_services(compose_dict: dict) -> list[ServiceConfig]:


def validate_healthchecks(services: list[ServiceConfig]) -> list[str]:
"""Return list of app services missing healthchecks."""
return [s.name for s in services if s.is_app and not s.has_healthcheck]
"""Return list of app services missing healthchecks.

Services with deploy.healthcheck.skip=true are excluded from validation.
"""
return [
s.name for s in services if s.is_app and not s.has_healthcheck and not s.healthcheck_skip
]
16 changes: 11 additions & 5 deletions src/flow_deploy/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def deploy(
if tag is None:
tag = "latest"


if dry_run:
# Dry run: just parse config from current checkout, no git or lock
try:
Expand Down Expand Up @@ -187,13 +188,18 @@ def _deploy_service(
new_id = new["ID"]
old_id = old["ID"]

# 4. Wait for health check
log.step(f"waiting for health check (timeout: {svc.healthcheck_timeout}s)...")
healthy = _wait_for_healthy(new_id, svc.healthcheck_timeout, svc.healthcheck_poll)
# 4. Wait for health check (skip if deploy.healthcheck.skip)
if svc.healthcheck_skip:
log.step("healthcheck skipped (deploy.healthcheck.skip=true)")
healthy = True
else:
log.step(f"waiting for health check (timeout: {svc.healthcheck_timeout}s)...")
healthy = _wait_for_healthy(new_id, svc.healthcheck_timeout, svc.healthcheck_poll)

if healthy:
health_elapsed = time.time() - svc_start
log.step(f"healthy ({health_elapsed:.1f}s)")
if not svc.healthcheck_skip:
health_elapsed = time.time() - svc_start
log.step(f"healthy ({health_elapsed:.1f}s)")

# 5a. Cutover: stop old, remove old, scale back
log.step(f"draining old container ({old_id[:7]}, {svc.drain}s timeout)...")
Expand Down
40 changes: 40 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,46 @@ def test_validate_healthchecks_all_good():
assert validate_healthchecks(services) == []


def test_healthcheck_skip_excludes_from_validation():
d = _compose_dict(
(
"web",
{
"image": "app:latest",
"labels": {"deploy.role": "app"},
"healthcheck": {"test": ["CMD", "true"]},
},
),
(
"beat",
{
"image": "app:latest",
"labels": {"deploy.role": "app", "deploy.healthcheck.skip": "true"},
},
),
)
services = parse_services(d)
beat = next(s for s in services if s.name == "beat")
assert beat.healthcheck_skip
assert not beat.has_healthcheck
assert validate_healthchecks(services) == []


def test_healthcheck_skip_false_by_default():
d = _compose_dict(
(
"web",
{
"image": "app:latest",
"labels": {"deploy.role": "app"},
"healthcheck": {"test": ["CMD", "true"]},
},
),
)
svc = parse_services(d)[0]
assert not svc.healthcheck_skip


def test_empty_services():
assert parse_services({"services": {}}) == []
assert parse_services({}) == []
Expand Down
35 changes: 35 additions & 0 deletions tests/test_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,3 +344,38 @@ def test_deploy_dirty_tree_fails(mock_process, monkeypatch, tmp_path, capsys):
assert result == 1
err = capsys.readouterr().err
assert "dirty" in err


def test_deploy_healthcheck_skip(mock_process, monkeypatch, tmp_path):
"""Services with deploy.healthcheck.skip=true skip health check waiting."""
_chdir(monkeypatch, tmp_path)
config_with_skip = """\
services:
beat:
image: app:latest
labels:
deploy.role: app
deploy.healthcheck.skip: "true"
deploy.drain: "1"
"""
mock_process.responses.extend(
[
*_git_preflight(),
_ok(config_with_skip),
# beat: pull
_ok(),
# beat: scale to 2
_ok(),
# beat: docker ps
_ok(WEB_CONTAINER_OLD + "\n" + WEB_CONTAINER_NEW + "\n"),
# beat: no health check poll — skips straight to cutover
# beat: docker stop old
_ok(),
# beat: docker rm old
_ok(),
# beat: scale back to 1
_ok(),
]
)
result = deploy(tag="abc123", cmd=COMPOSE_CMD)
assert result == 0
Loading