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
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
]
44 changes: 28 additions & 16 deletions src/flow_deploy/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,24 @@ def deploy(
"""Perform a rolling deploy. Returns exit code (0=success, 1=failure, 2=locked, 3=skipped)."""
compose_cmd = cmd or compose.resolve_command()

# Parse compose config
# Determine tag
if tag is None:
tag = tags.current_tag() or "latest"

if not dry_run:
# Git pre-flight: dirty check, fetch, checkout detached.
# Must happen before compose config so we parse the target state.
git_code, previous_sha = git.preflight_and_checkout(tag)
if git_code != 0:
return 1

# Parse compose config (now at the target SHA)
try:
compose_dict = compose.compose_config(cmd=compose_cmd)
except RuntimeError as e:
log.error(str(e))
if not dry_run:
git.restore(previous_sha)
return 1

all_services = config.parse_services(compose_dict)
Expand All @@ -30,28 +43,22 @@ def deploy(

if not app_services:
log.error("No app services to deploy")
if not dry_run:
git.restore(previous_sha)
return 1

# Validate healthchecks
missing = config.validate_healthchecks(app_services)
if missing:
log.error(f"Services missing healthcheck: {', '.join(missing)}")
if not dry_run:
git.restore(previous_sha)
return 1

# Determine tag
if tag is None:
# Use whatever is in compose config (no override)
tag = tags.current_tag() or "latest"

if dry_run:
_dry_run(tag, app_services)
return 0

# Git pre-flight: dirty check, fetch, checkout detached
git_code, previous_sha = git.preflight_and_checkout(tag)
if git_code != 0:
return 1

# Acquire lock
if not lock.acquire():
lock_info = lock.read_lock()
Expand Down Expand Up @@ -161,13 +168,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
82 changes: 69 additions & 13 deletions tests/test_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,10 @@ def _setup_happy_path(mock_process, monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
mock_process.responses.extend(
[
# git preflight (now runs before compose config)
*_git_preflight(),
# compose config
_ok(COMPOSE_CONFIG_YAML),
# git preflight
*_git_preflight(),
# web: pull
_ok(),
# web: scale to 2
Expand Down Expand Up @@ -136,8 +136,8 @@ def test_deploy_service_filter(mock_process, monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
mock_process.responses.extend(
[
_ok(COMPOSE_CONFIG_YAML),
*_git_preflight(),
_ok(COMPOSE_CONFIG_YAML),
# web only: pull, scale, ps, health, stop, rm, scale back
_ok(),
_ok(),
Expand All @@ -154,6 +154,7 @@ def test_deploy_service_filter(mock_process, monkeypatch, tmp_path):

def test_deploy_dry_run(mock_process, monkeypatch, tmp_path, capsys):
monkeypatch.chdir(tmp_path)
# dry_run skips git preflight, only needs compose config
mock_process.responses.append(_ok(COMPOSE_CONFIG_YAML))
result = deploy(tag="abc123", dry_run=True, cmd=COMPOSE_CMD)
assert result == 0
Expand All @@ -171,8 +172,8 @@ def test_deploy_health_check_failure(mock_process, monkeypatch, tmp_path):
monkeypatch.setattr("flow_deploy.deploy._wait_for_healthy", lambda *a, **kw: False)
mock_process.responses.extend(
[
_ok(COMPOSE_CONFIG_YAML),
*_git_preflight(),
_ok(COMPOSE_CONFIG_YAML),
# web: pull
_ok(),
# web: scale to 2
Expand All @@ -197,8 +198,8 @@ def test_deploy_pull_failure(mock_process, monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
mock_process.responses.extend(
[
_ok(COMPOSE_CONFIG_YAML),
*_git_preflight(),
_ok(COMPOSE_CONFIG_YAML),
_err("pull failed"),
# git restore to previous SHA
_ok(),
Expand All @@ -212,8 +213,8 @@ def test_deploy_lock_held(mock_process, monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
mock_process.responses.extend(
[
_ok(COMPOSE_CONFIG_YAML),
*_git_preflight(),
_ok(COMPOSE_CONFIG_YAML),
# git restore after lock rejection
_ok(),
]
Expand All @@ -238,22 +239,43 @@ def test_deploy_missing_healthcheck(mock_process, monkeypatch, tmp_path):
labels:
deploy.role: app
"""
mock_process.responses.append(_ok(config_no_hc))
mock_process.responses.extend(
[
*_git_preflight(),
_ok(config_no_hc),
# git restore after validation failure
_ok(),
]
)
result = deploy(tag="abc123", cmd=COMPOSE_CMD)
assert result == 1


def test_deploy_no_services(mock_process, monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
config_empty = "services:\n redis:\n image: redis:7\n"
mock_process.responses.append(_ok(config_empty))
mock_process.responses.extend(
[
*_git_preflight(),
_ok(config_empty),
# git restore after no services found
_ok(),
]
)
result = deploy(tag="abc123", cmd=COMPOSE_CMD)
assert result == 1


def test_deploy_compose_config_failure(mock_process, monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
mock_process.responses.append(_err("compose error"))
mock_process.responses.extend(
[
*_git_preflight(),
_err("compose error"),
# git restore after compose config failure
_ok(),
]
)
result = deploy(tag="abc123", cmd=COMPOSE_CMD)
assert result == 1

Expand All @@ -271,8 +293,8 @@ def test_deploy_container_count_mismatch(mock_process, monkeypatch, tmp_path):
"""
mock_process.responses.extend(
[
_ok(single_svc_config),
*_git_preflight(),
_ok(single_svc_config),
_ok(), # pull
_ok(), # scale to 2
_ok(WEB_CONTAINER_OLD + "\n"), # only 1 container returned
Expand Down Expand Up @@ -314,8 +336,8 @@ def test_rollback(mock_process, monkeypatch, tmp_path):
"""
mock_process.responses.extend(
[
_ok(single_svc_config),
*_git_preflight(),
_ok(single_svc_config),
_ok(),
_ok(),
_ok(WEB_CONTAINER_OLD + "\n" + WEB_CONTAINER_NEW + "\n"),
Expand All @@ -333,8 +355,7 @@ def test_deploy_dirty_tree_fails(mock_process, monkeypatch, tmp_path, capsys):
monkeypatch.chdir(tmp_path)
mock_process.responses.extend(
[
_ok(COMPOSE_CONFIG_YAML),
# git status --porcelain returns dirty
# git status --porcelain returns dirty (preflight runs first now)
_ok(" M somefile.py\n"),
]
)
Expand All @@ -348,3 +369,38 @@ def test_rollback_no_previous(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
result = rollback(cmd=COMPOSE_CMD)
assert result == 1


def test_deploy_healthcheck_skip(mock_process, monkeypatch, tmp_path):
"""Services with deploy.healthcheck.skip=true skip health check waiting."""
monkeypatch.chdir(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