diff --git a/README.md b/README.md index bddd7c6..ddb6e55 100644 --- a/README.md +++ b/README.md @@ -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:** @@ -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 diff --git a/src/flow_deploy/config.py b/src/flow_deploy/config.py index da74ffb..588e7c7 100644 --- a/src/flow_deploy/config.py +++ b/src/flow_deploy/config.py @@ -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 @@ -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") @@ -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, @@ -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 + ] diff --git a/src/flow_deploy/deploy.py b/src/flow_deploy/deploy.py index cf5754c..e30d54b 100644 --- a/src/flow_deploy/deploy.py +++ b/src/flow_deploy/deploy.py @@ -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: @@ -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)...") diff --git a/tests/test_config.py b/tests/test_config.py index 4a42a93..8a86de9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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({}) == [] diff --git a/tests/test_deploy.py b/tests/test_deploy.py index 5a679a9..3736df4 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -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