diff --git a/README.md b/README.md index 736d289..7b92929 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 ed54de4..b16297f 100644 --- a/src/flow_deploy/deploy.py +++ b/src/flow_deploy/deploy.py @@ -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) @@ -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() @@ -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)...") 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 9d6edff..073dd89 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -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 @@ -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(), @@ -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 @@ -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 @@ -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(), @@ -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(), ] @@ -238,7 +239,14 @@ 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 @@ -246,14 +254,28 @@ def test_deploy_missing_healthcheck(mock_process, monkeypatch, tmp_path): 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 @@ -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 @@ -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"), @@ -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"), ] ) @@ -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