diff --git a/.gitignore b/.gitignore index ed6b4fc..3a23870 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,4 @@ .env .omc/ __pycache__/ -.deploy-tag -.deploy-lock .site-dev/ diff --git a/README.md b/README.md index 736d289..bddd7c6 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Minimal, opinionated rolling deploys for Docker Compose + Traefik stacks. -Replaces Kamal's useful subset — rolling deploys, health checks, automatic rollback — without its baggage. +Replaces Kamal's useful subset — rolling deploys, health checks, automatic failure recovery — without its baggage. ## Install @@ -67,7 +67,7 @@ For each `deploy.role=app` service, in order: 2. **Scale to 2** — start a new container alongside the old one 3. **Health check** — poll the new container until healthy or timeout 4. **Cutover** — if healthy, gracefully drain the old container and scale back to 1 -5. **Rollback** — if unhealthy, remove the new container. Old container is untouched. +5. **Abort** — if unhealthy, remove the new container. Old container is untouched. ## Service Roles @@ -118,7 +118,6 @@ See [GitHub Actions Setup](docs/github-actions.md) for CI/CD integration. ``` flow-deploy deploy [--tag TAG] [--service NAME] [--dry-run] -flow-deploy rollback [--service NAME] flow-deploy status flow-deploy exec SERVICE COMMAND... flow-deploy logs SERVICE [-f] [-n LINES] diff --git a/SPEC.md b/SPEC.md index 2819b76..c26360c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,6 +1,6 @@ # Flow Deploy — Specification -A minimal, opinionated deployment tool for Docker Compose + Traefik stacks. Replaces Kamal's useful subset (rolling deploys, health checks, automatic rollback) without its baggage (parallel config, local-first execution, registry coupling). +A minimal, opinionated deployment tool for Docker Compose + Traefik stacks. Replaces Kamal's useful subset (rolling deploys, health checks, automatic failure recovery) without its baggage (parallel config, local-first execution, registry coupling). --- @@ -23,7 +23,7 @@ Every service in `docker-compose.yml` is classified by a label: | Label | Behavior | |---|---| -| `deploy.role=app` | Rolled during deploy. Health-checked. Rolled back on failure. | +| `deploy.role=app` | Rolled during deploy. Health-checked. Old container preserved on failure. | | `deploy.role=accessory` | Never touched during deploy. Only started/stopped via explicit commands. | | *(no label)* | Ignored entirely. The tool does not interact with unlabeled services. | @@ -85,7 +85,7 @@ Configuration (via labels on the service): | Label | Default | Description | |---|---|---| -| `deploy.healthcheck.timeout` | `120` | Seconds to wait for healthy before rollback | +| `deploy.healthcheck.timeout` | `120` | Seconds to wait for healthy before aborting | | `deploy.healthcheck.poll` | `2` | Seconds between health status polls | ### 2.5 Deploy Order @@ -213,7 +213,7 @@ services: image: ghcr.io/myorg/myapp:${DEPLOY_TAG:-latest} ``` -The tool writes the deployed tag to a file (`.deploy-tag`) in the project root for introspection. +The deployed SHA is always available via `git rev-parse HEAD` — the server is kept in detached HEAD at the deployed commit. The tool does not write any state files to the working tree. --- @@ -230,11 +230,11 @@ The tool expects a project directory containing a checked-out Git repository wit │ └── prod # Production compose wrapper ├── Dockerfile ├── .env # Environment variables (secrets) -├── .deploy-tag # Written by the tool: current deployed tag -├── .deploy-lock # Written by the tool: deploy lock file └── (application source) ``` +The tool writes no state files to the working tree. The deploy lock is stored at `.git/deploy-lock` (invisible to `git status`). The current deployed SHA is always `git rev-parse HEAD`. + ### 3.1 Compose Command Wrapper The tool never calls `docker compose` directly. It delegates to a compose wrapper script that knows which override files to use for the current environment. @@ -296,20 +296,12 @@ flow-deploy deploy [--tag TAG] [--service SERVICE] [--dry-run] Exit codes: - `0` — all services deployed successfully -- `1` — one or more services failed, rolled back +- `1` — deploy failed (dirty tree, git error, unhealthy service, etc.) - `2` — deploy lock held by another process -### 4.2 `flow-deploy rollback` - -Rollback to the previously deployed image tag. Reads the prior tag from `.deploy-tag` history (the tool maintains the last 10 tags). - -``` -flow-deploy rollback [--service SERVICE] -``` - -This performs the same rolling deploy lifecycle using the previous tag. +To deploy a previous version, run `flow-deploy deploy --tag `. The SHA is always a git commit — find it in your CI history or via `git log` on the server. -### 4.3 `flow-deploy status` +### 4.2 `flow-deploy status` Show the current state of all managed services. @@ -317,9 +309,9 @@ Show the current state of all managed services. flow-deploy status ``` -Output includes: service name, role, container ID, image tag, health status, uptime. +Output includes: current HEAD SHA, service name, role, container ID, image tag, health status. -### 4.4 `flow-deploy exec` +### 4.3 `flow-deploy exec` Run a command inside a running service container. Convenience wrapper around `docker compose exec`. @@ -327,7 +319,7 @@ Run a command inside a running service container. Convenience wrapper around `do flow-deploy exec ``` -### 4.5 `flow-deploy logs` +### 4.4 `flow-deploy logs` Tail logs for a service. Convenience wrapper around `docker compose logs`. @@ -335,7 +327,7 @@ Tail logs for a service. Convenience wrapper around `docker compose logs`. flow-deploy logs [--follow] [--tail N] ``` -### 4.6 `flow-deploy config` +### 4.5 `flow-deploy config` Output resolved deploy configuration as JSON. Used by the GitHub Action to discover hosts without requiring the Python source tree. @@ -349,7 +341,7 @@ flow-deploy config [--command COMMAND] Reads `COMPOSE_COMMAND` env var (same as all other commands), runs ` config`, parses host discovery, and emits a JSON array of host groups to stdout. Errors go to stderr with exit code 1. -### 4.7 `flow-deploy upgrade` +### 4.6 `flow-deploy upgrade` Update the tool to the latest release. @@ -363,10 +355,11 @@ This detects the system's libc (musl or glibc), downloads the latest binary from ## 5. Deploy Locking -Only one deploy may run at a time per project directory. The tool uses a lock file (`.deploy-lock`) containing the PID and timestamp of the running deploy. The lock is: +Only one deploy may run at a time per project directory. The tool uses a lock file (`.git/deploy-lock`) containing the PID and timestamp of the running deploy. The lock is stored inside `.git/` to avoid dirtying the working tree. The lock is: -- Acquired at the start of `deploy` or `rollback` +- Acquired before any mutations (git checkout, container changes) - Released on completion (success or failure) +- **Retained** if git restore fails after a failed deploy — prevents further deploys until manual intervention (the error message includes the fix: `git checkout --detach && rm .git/deploy-lock`) - Automatically broken if the holding PID is no longer running (stale lock recovery) - Reported with a clear message if held by another process (exit code 2) @@ -411,8 +404,8 @@ Failure output: [12:35:13] starting new container... [12:35:13] waiting for health check (timeout: 120s)... [12:37:13] ✗ health check timeout (120.0s) -[12:37:13] rolling back: stopping new container (x9y8z7w)... -[12:37:14] rollback complete, old container still serving +[12:37:13] aborting: stopping new container (x9y8z7w)... +[12:37:14] aborted, old container still serving [12:37:14] ✗ worker FAILED [12:37:14] [12:37:14] restoring repo to a1b2c3d... @@ -620,7 +613,7 @@ The tool has zero configuration files. All behavior is controlled by `x-deploy` | `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 | -| `deploy.healthcheck.timeout` | No | `120` | Seconds before rollback | +| `deploy.healthcheck.timeout` | No | `120` | Seconds before aborting | | `deploy.healthcheck.poll` | No | `2` | Seconds between polls | **Environment variable overrides** (highest priority, set via GitHub Actions environment): @@ -657,7 +650,7 @@ All v1 design decisions are made with the v2 roadmap in mind. Specifically: - The single-command `flow-deploy deploy` is a convenience that runs prepare + health check + cutover in one shot. v2 splits this into discrete phases without breaking the v1 interface. - The `deploy.role` label convention is extensible — v2 adds behavior, not new classification schemes. - The compose command wrapper (§3.1) means the tool never makes assumptions about compose file structure, which keeps it compatible with arbitrarily complex service topologies. -- The `.deploy-tag` file is the only state the tool writes. v2 adds `.deploy-prepare` for two-phase state tracking, but the tag history mechanism is unchanged. +- The tool writes no state to the working tree. The deploy lock lives in `.git/deploy-lock`, and the current deployed SHA is always `git rev-parse HEAD`. v2 adds `.git/deploy-prepare` for two-phase state tracking. - Host discovery via `x-deploy` and `deploy.host` labels (§2.6) gives the GitHub Action enough information to orchestrate multi-host deploys in v1 (sequential) and v2 (two-phase coordinated). --- diff --git a/site/index.html b/site/index.html index 4d63776..16e2d4f 100644 --- a/site/index.html +++ b/site/index.html @@ -63,7 +63,7 @@

A minimal, opinionated deployment tool for Docker Compose + Traefik stacks. - Rolling deploys, health checks, automatic rollback. + Rolling deploys, health checks, automatic failure recovery. Your docker-compose.yml is the single source of truth.

@@ -174,7 +174,7 @@

Health check

4
-

Cutover or rollback

+

Cutover or abort

Healthy? Graceful shutdown of old container, scale back to 1.
Unhealthy? Remove new container, old continues serving. Exit 1. @@ -237,10 +237,6 @@

CLI Commands

deploy

Rolling deploy of all app services. Supports --tag, --service, --dry-run.

-
- rollback -

Rollback to the previously deployed image tag from .deploy-tag history.

-
status

Show current state of all managed services: container ID, image, health, uptime.

diff --git a/src/flow_deploy/cli.py b/src/flow_deploy/cli.py index eee4248..251ac69 100644 --- a/src/flow_deploy/cli.py +++ b/src/flow_deploy/cli.py @@ -7,7 +7,7 @@ from flow_deploy import __version__, compose from flow_deploy import deploy as deploy_mod -from flow_deploy import discovery, log, process, tags +from flow_deploy import discovery, log, process @click.group() @@ -27,15 +27,6 @@ def deploy(tag, service, dry_run): sys.exit(code) -@main.command() -@click.option("--service", multiple=True, help="Rollback only specific service(s)") -def rollback(service): - """Rollback to the previously deployed image tag.""" - services_filter = list(service) if service else None - code = deploy_mod.rollback(services_filter=services_filter) - sys.exit(code) - - @main.command() def status(): """Show current state of all managed services.""" @@ -45,12 +36,12 @@ def status(): log.error(str(e)) sys.exit(1) - from flow_deploy import config, containers + from flow_deploy import config, containers, git all_services = config.parse_services(compose_dict) - current = tags.current_tag() + current_sha = git.current_sha() - log.info(f"Current tag: {current or '(none)'}") + log.info(f"Current SHA: {current_sha[:7] if current_sha else '(none)'}") log.info("") for svc in all_services: diff --git a/src/flow_deploy/deploy.py b/src/flow_deploy/deploy.py index ed54de4..cf5754c 100644 --- a/src/flow_deploy/deploy.py +++ b/src/flow_deploy/deploy.py @@ -1,9 +1,9 @@ -"""Core deploy algorithm + rollback.""" +"""Core deploy algorithm.""" import signal import time -from flow_deploy import compose, config, containers, git, lock, log, tags +from flow_deploy import compose, config, containers, git, lock, log def deploy( @@ -12,53 +12,31 @@ def deploy( dry_run: bool = False, cmd: list[str] | None = None, ) -> int: - """Perform a rolling deploy. Returns exit code (0=success, 1=failure, 2=locked, 3=skipped).""" + """Perform a rolling deploy. Returns exit code (0=success, 1=failure, 2=locked).""" compose_cmd = cmd or compose.resolve_command() - # Parse compose config - try: - compose_dict = compose.compose_config(cmd=compose_cmd) - except RuntimeError as e: - log.error(str(e)) - return 1 - - all_services = config.parse_services(compose_dict) - app_services = [s for s in all_services if s.is_app] - - if services_filter: - app_services = [s for s in app_services if s.name in services_filter] - - if not app_services: - log.error("No app services to deploy") - return 1 - - # Validate healthchecks - missing = config.validate_healthchecks(app_services) - if missing: - log.error(f"Services missing healthcheck: {', '.join(missing)}") - return 1 - # Determine tag if tag is None: - # Use whatever is in compose config (no override) - tag = tags.current_tag() or "latest" + tag = "latest" if dry_run: + # Dry run: just parse config from current checkout, no git or lock + try: + compose_dict = compose.compose_config(cmd=compose_cmd) + except RuntimeError as e: + log.error(str(e)) + return 1 + app_services = _resolve_app_services(compose_dict, services_filter) + if app_services is None: + return 1 _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 + # Acquire lock before any mutations (git checkout, container changes) if not lock.acquire(): lock_info = lock.read_lock() pid = lock_info["pid"] if lock_info else "unknown" log.error(f"Deploy lock held by PID {pid}") - # Restore repo to previous state before exiting - git.restore(previous_sha) return 2 # Register signal handlers for cleanup @@ -73,7 +51,29 @@ def _cleanup_handler(signum, frame): signal.signal(signal.SIGTERM, _cleanup_handler) signal.signal(signal.SIGINT, _cleanup_handler) + keep_lock = False try: + # Git pre-flight: dirty check, fetch, checkout detached. + # Protected by the lock so concurrent deploys can't race on checkout. + # Must happen before config parsing so we read the compose file + # from the target commit, not whatever is currently checked out. + git_code, previous_sha = git.preflight_and_checkout(tag) + if git_code != 0: + return 1 + + # Parse compose config (now reading from the target commit) + try: + compose_dict = compose.compose_config(cmd=compose_cmd) + except RuntimeError as e: + log.error(str(e)) + keep_lock = not git.restore(previous_sha) + return 1 + + app_services = _resolve_app_services(compose_dict, services_filter) + if app_services is None: + keep_lock = not git.restore(previous_sha) + return 1 + service_names = ", ".join(s.name for s in app_services) log.header("deploy") log.info(f"tag: {tag}") @@ -87,25 +87,51 @@ def _cleanup_handler(signum, frame): result = _deploy_service(svc, tag, compose_cmd, project=project) if result != 0: log.info("") - git.restore(previous_sha) + keep_lock = not git.restore(previous_sha) log.footer("FAILED (deploy aborted)") - lock.release() return 1 elapsed = time.time() - start_time - tags.write_tag(tag) log.info("") log.info(f"HEAD detached at {tag}") log.footer(f"complete ({elapsed:.1f}s)") finally: - lock.release() + if keep_lock: + log.error( + "Lock retained — git restore failed, manual intervention required. " + "Run: git checkout --detach && rm .git/deploy-lock" + ) + else: + lock.release() signal.signal(signal.SIGTERM, _original_sigterm) signal.signal(signal.SIGINT, _original_sigint) return 0 +def _resolve_app_services( + compose_dict: dict, services_filter: list[str] | None +) -> list[config.ServiceConfig] | None: + """Parse and validate app services from compose config. Returns None on error.""" + all_services = config.parse_services(compose_dict) + app_services = [s for s in all_services if s.is_app] + + if services_filter: + app_services = [s for s in app_services if s.name in services_filter] + + if not app_services: + log.error("No app services to deploy") + return None + + missing = config.validate_healthchecks(app_services) + if missing: + log.error(f"Services missing healthcheck: {', '.join(missing)}") + return None + + return app_services + + def _deploy_service( svc: config.ServiceConfig, tag: str, compose_cmd: list[str], project: str = "" ) -> int: @@ -180,12 +206,12 @@ def _deploy_service( log.service_end() return 0 else: - # 5b. Rollback: stop new, remove new, scale back - log.step(f"rolling back: stopping new container ({new_id[:7]})...") + # 5b. Abort: stop new, remove new, scale back + log.step(f"aborting: stopping new container ({new_id[:7]})...") containers.stop_container(new_id) containers.remove_container(new_id) _scale_back(svc.name, env, compose_cmd) - log.step("rollback complete, old container still serving") + log.step("aborted, old container still serving") log.failure(f"{svc.name} FAILED") log.service_end() return 1 @@ -228,17 +254,3 @@ def _dry_run(tag: str, services: list[config.ServiceConfig]) -> None: log.step("would scale back to 1") log.service_end() log.footer("dry-run complete") - - -def rollback( - services_filter: list[str] | None = None, - cmd: list[str] | None = None, -) -> int: - """Rollback to the previous tag. Returns exit code.""" - prev = tags.previous_tag() - if prev is None: - log.error("No previous tag to rollback to") - return 1 - - log.info(f"Rolling back to tag: {prev}") - return deploy(tag=prev, services_filter=services_filter, cmd=cmd) diff --git a/src/flow_deploy/lock.py b/src/flow_deploy/lock.py index 9e96769..e57adf5 100644 --- a/src/flow_deploy/lock.py +++ b/src/flow_deploy/lock.py @@ -1,10 +1,13 @@ -""".deploy-lock acquire/release/stale recovery.""" +"""Deploy lock — acquire/release/stale recovery. + +Lock file lives in .git/ to avoid dirtying the working tree. +""" import json import os import time -LOCK_FILE = ".deploy-lock" +LOCK_FILE = ".git/deploy-lock" def _lock_path() -> str: diff --git a/src/flow_deploy/tags.py b/src/flow_deploy/tags.py deleted file mode 100644 index 0d77617..0000000 --- a/src/flow_deploy/tags.py +++ /dev/null @@ -1,42 +0,0 @@ -""".deploy-tag history — newline-delimited, newest last, max 10.""" - -TAG_FILE = ".deploy-tag" -MAX_HISTORY = 10 - - -def _tag_path() -> str: - return TAG_FILE - - -def read_tags() -> list[str]: - """Read tag history. Returns list with oldest first, newest last.""" - path = _tag_path() - try: - with open(path) as f: - tags = [line.strip() for line in f if line.strip()] - return tags - except FileNotFoundError: - return [] - - -def current_tag() -> str | None: - """Return the most recently deployed tag, or None.""" - tags = read_tags() - return tags[-1] if tags else None - - -def previous_tag() -> str | None: - """Return the tag before the current one, or None.""" - tags = read_tags() - return tags[-2] if len(tags) >= 2 else None - - -def write_tag(tag: str) -> None: - """Append a tag to history, trimming to MAX_HISTORY.""" - tags = read_tags() - tags.append(tag) - tags = tags[-MAX_HISTORY:] - path = _tag_path() - with open(path, "w") as f: - for t in tags: - f.write(t + "\n") diff --git a/tests/test_cli.py b/tests/test_cli.py index 75bc64d..b1c496c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -44,32 +44,6 @@ def test_deploy_exit_code_propagated(mock_deploy): assert result.exit_code == 2 -@patch("flow_deploy.deploy.rollback") -def test_rollback(mock_rollback): - mock_rollback.return_value = 0 - runner = CliRunner() - result = runner.invoke(main, ["rollback"]) - assert result.exit_code == 0 - mock_rollback.assert_called_once_with(services_filter=None) - - -@patch("flow_deploy.deploy.rollback") -def test_rollback_with_service(mock_rollback): - mock_rollback.return_value = 0 - runner = CliRunner() - result = runner.invoke(main, ["rollback", "--service", "web"]) - assert result.exit_code == 0 - mock_rollback.assert_called_once_with(services_filter=["web"]) - - -@patch("flow_deploy.deploy.rollback") -def test_rollback_failure(mock_rollback): - mock_rollback.return_value = 1 - runner = CliRunner() - result = runner.invoke(main, ["rollback"]) - assert result.exit_code == 1 - - def test_version(): runner = CliRunner() result = runner.invoke(main, ["--version"]) @@ -179,7 +153,6 @@ def test_help(): result = runner.invoke(main, ["--help"]) assert result.exit_code == 0 assert "deploy" in result.output - assert "rollback" in result.output assert "status" in result.output assert "exec" in result.output assert "logs" in result.output diff --git a/tests/test_deploy.py b/tests/test_deploy.py index 9d6edff..5a679a9 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -1,9 +1,9 @@ -"""Tests for deploy.py — full deploy lifecycle, rollback, dry-run.""" +"""Tests for deploy.py — full deploy lifecycle, dry-run.""" import json from flow_deploy import process -from flow_deploy.deploy import deploy, rollback +from flow_deploy.deploy import deploy COMPOSE_CMD = ["docker", "compose"] PREV_SHA = "prev123abc" @@ -81,15 +81,21 @@ def _git_preflight(): ] +def _chdir(monkeypatch, tmp_path): + """Set working directory and ensure .git/ exists for lock file.""" + (tmp_path / ".git").mkdir(exist_ok=True) + monkeypatch.chdir(tmp_path) + + def _setup_happy_path(mock_process, monkeypatch, tmp_path): """Set up mock responses for a successful 2-service deploy.""" - monkeypatch.chdir(tmp_path) + _chdir(monkeypatch, tmp_path) mock_process.responses.extend( [ + # git preflight (before compose config) + *_git_preflight(), # compose config _ok(COMPOSE_CONFIG_YAML), - # git preflight - *_git_preflight(), # web: pull _ok(), # web: scale to 2 @@ -126,18 +132,14 @@ def test_deploy_happy_path(mock_process, monkeypatch, tmp_path): _setup_happy_path(mock_process, monkeypatch, tmp_path) result = deploy(tag="abc123", cmd=COMPOSE_CMD) assert result == 0 - # Verify tag was written - tag_file = tmp_path / ".deploy-tag" - assert tag_file.exists() - assert "abc123" in tag_file.read_text() def test_deploy_service_filter(mock_process, monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) + _chdir(monkeypatch, 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(), @@ -153,7 +155,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) + _chdir(monkeypatch, tmp_path) mock_process.responses.append(_ok(COMPOSE_CONFIG_YAML)) result = deploy(tag="abc123", dry_run=True, cmd=COMPOSE_CMD) assert result == 0 @@ -162,24 +164,24 @@ def test_deploy_dry_run(mock_process, monkeypatch, tmp_path, capsys): assert "web" in out assert "worker" in out # No lock file should exist - assert not (tmp_path / ".deploy-lock").exists() + assert not (tmp_path / ".git" / "deploy-lock").exists() def test_deploy_health_check_failure(mock_process, monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) + _chdir(monkeypatch, tmp_path) # Patch _wait_for_healthy to avoid real sleep 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 _ok(), # web: docker ps _ok(WEB_CONTAINER_OLD + "\n" + WEB_CONTAINER_NEW + "\n"), - # web: stop new (rollback) + # web: stop new (abort) _ok(), # web: rm new _ok(), @@ -194,11 +196,11 @@ def test_deploy_health_check_failure(mock_process, monkeypatch, tmp_path): def test_deploy_pull_failure(mock_process, monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) + _chdir(monkeypatch, 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(), @@ -209,15 +211,8 @@ def test_deploy_pull_failure(mock_process, monkeypatch, tmp_path): def test_deploy_lock_held(mock_process, monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) - mock_process.responses.extend( - [ - _ok(COMPOSE_CONFIG_YAML), - *_git_preflight(), - # git restore after lock rejection - _ok(), - ] - ) + _chdir(monkeypatch, tmp_path) + # No git or compose responses needed — lock check happens first # Pre-acquire lock with current PID from flow_deploy import lock @@ -230,7 +225,7 @@ def test_deploy_lock_held(mock_process, monkeypatch, tmp_path): def test_deploy_missing_healthcheck(mock_process, monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) + _chdir(monkeypatch, tmp_path) config_no_hc = """\ services: web: @@ -238,28 +233,46 @@ 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), + _ok(), # git restore + ] + ) result = deploy(tag="abc123", cmd=COMPOSE_CMD) assert result == 1 def test_deploy_no_services(mock_process, monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) + _chdir(monkeypatch, 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), + _ok(), # git restore + ] + ) 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")) + _chdir(monkeypatch, tmp_path) + mock_process.responses.extend( + [ + *_git_preflight(), + _err("compose error"), + _ok(), # git restore + ] + ) result = deploy(tag="abc123", cmd=COMPOSE_CMD) assert result == 1 def test_deploy_container_count_mismatch(mock_process, monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) + _chdir(monkeypatch, tmp_path) single_svc_config = """\ services: web: @@ -271,8 +284,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 @@ -294,47 +307,36 @@ def test_deploy_order(mock_process, monkeypatch, tmp_path, capsys): assert web_pos < worker_pos -def test_rollback(mock_process, monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) - # Write tag history - from flow_deploy import tags - - tags.write_tag("v1") - tags.write_tag("v2") - - # Setup deploy responses for rollback to v1 - single_svc_config = """\ -services: - web: - image: app:latest - labels: - deploy.role: app - healthcheck: - test: ["CMD", "true"] -""" +def test_deploy_restore_failure_retains_lock(mock_process, monkeypatch, tmp_path, capsys): + """If git restore fails after a deploy failure, the lock should be retained.""" + _chdir(monkeypatch, tmp_path) mock_process.responses.extend( [ - _ok(single_svc_config), *_git_preflight(), - _ok(), - _ok(), - _ok(WEB_CONTAINER_OLD + "\n" + WEB_CONTAINER_NEW + "\n"), - _ok("healthy\n"), - _ok(), - _ok(), - _ok(), + _ok(COMPOSE_CONFIG_YAML), + # web: pull fails + _err("pull failed"), + # git restore fails + _err("checkout error"), ] ) - result = rollback(cmd=COMPOSE_CMD) - assert result == 0 + result = deploy(tag="abc123", cmd=COMPOSE_CMD) + assert result == 1 + # Lock should still be held + from flow_deploy import lock + + assert not lock.acquire(), "Lock should still be held after restore failure" + err = capsys.readouterr().err + assert "Lock retained" in err + # Clean up for other tests + lock.release() def test_deploy_dirty_tree_fails(mock_process, monkeypatch, tmp_path, capsys): - monkeypatch.chdir(tmp_path) + _chdir(monkeypatch, tmp_path) mock_process.responses.extend( [ - _ok(COMPOSE_CONFIG_YAML), - # git status --porcelain returns dirty + # git status --porcelain returns dirty (before compose config) _ok(" M somefile.py\n"), ] ) @@ -342,9 +344,3 @@ 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_rollback_no_previous(monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) - result = rollback(cmd=COMPOSE_CMD) - assert result == 1 diff --git a/tests/test_lock.py b/tests/test_lock.py index e8ab734..83143ef 100644 --- a/tests/test_lock.py +++ b/tests/test_lock.py @@ -3,11 +3,19 @@ import json import os +import pytest + from flow_deploy.lock import acquire, read_lock, release -def test_acquire_and_release(tmp_path, monkeypatch): +@pytest.fixture(autouse=True) +def _git_dir(tmp_path, monkeypatch): + """Create .git/ and chdir so the lock file has a home.""" + (tmp_path / ".git").mkdir() monkeypatch.chdir(tmp_path) + + +def test_acquire_and_release(): assert acquire() is True lock = read_lock() assert lock is not None @@ -17,18 +25,14 @@ def test_acquire_and_release(tmp_path, monkeypatch): assert read_lock() is None -def test_acquire_fails_when_held(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - # Write a lock with current PID (which is running) +def test_acquire_fails_when_held(): assert acquire() is True assert acquire() is False release() -def test_stale_lock_broken(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - # Write a lock with a PID that doesn't exist - with open(tmp_path / ".deploy-lock", "w") as f: +def test_stale_lock_broken(tmp_path): + with open(tmp_path / ".git" / "deploy-lock", "w") as f: json.dump({"pid": 999999999, "timestamp": 0}, f) assert acquire() is True lock = read_lock() @@ -36,19 +40,16 @@ def test_stale_lock_broken(tmp_path, monkeypatch): release() -def test_corrupt_lock_overwritten(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - with open(tmp_path / ".deploy-lock", "w") as f: +def test_corrupt_lock_overwritten(tmp_path): + with open(tmp_path / ".git" / "deploy-lock", "w") as f: f.write("not json") assert acquire() is True release() -def test_release_no_file(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) +def test_release_no_file(): release() # Should not raise -def test_read_lock_no_file(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) +def test_read_lock_no_file(): assert read_lock() is None diff --git a/tests/test_tags.py b/tests/test_tags.py deleted file mode 100644 index dc41963..0000000 --- a/tests/test_tags.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Tests for tags.py — deploy tag history.""" - -from flow_deploy.tags import current_tag, previous_tag, read_tags, write_tag - - -def test_read_empty(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - assert read_tags() == [] - - -def test_write_and_read(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - write_tag("abc123") - assert read_tags() == ["abc123"] - assert current_tag() == "abc123" - - -def test_multiple_tags(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - write_tag("v1") - write_tag("v2") - write_tag("v3") - assert read_tags() == ["v1", "v2", "v3"] - assert current_tag() == "v3" - assert previous_tag() == "v2" - - -def test_max_history(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - for i in range(15): - write_tag(f"tag-{i}") - tags = read_tags() - assert len(tags) == 10 - assert tags[0] == "tag-5" - assert tags[-1] == "tag-14" - - -def test_current_tag_none(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - assert current_tag() is None - - -def test_previous_tag_none(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - assert previous_tag() is None - - -def test_previous_tag_single(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - write_tag("only-one") - assert previous_tag() is None