From 6436becff975f38dd5704de4a96d38f62d782c2e Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 5 Jan 2026 15:55:00 +0100 Subject: [PATCH] up: Add --pull and --build flags for Docker Compose parity (#146) * up: Add --pull and --build flags for Docker Compose parity Add `--pull` and `--build` options to `cf up` to match Docker Compose naming conventions. This allows users to pull images or rebuild before starting without using the separate `update` command. - `cf up --pull` adds `--pull always` to the compose command - `cf up --build` adds `--build` to the compose command - Both flags work together: `cf up --pull --build` The `update` command remains unchanged as a convenience wrapper. * Update README.md * up: Run stacks in parallel when no migration needed Refactor up_stacks to categorize stacks and run them appropriately: - Simple stacks (no migration): run in parallel via asyncio.gather - Multi-host stacks: run in parallel - Migration stacks: run sequentially for clear output and rollback This makes `cf up --all` as fast as `cf update --all` for typical use. * refactor: DRY up command building with build_up_cmd helper Consolidate all 'up -d' command construction into a single helper function. Now used by up, update, and operations module. Added tests for the helper function. * update: Delegate to up --pull --build Simplify update command to just call up with pull=True and build=True. This removes duplication and ensures consistent behavior. --- README.md | 2 + src/compose_farm/cli/lifecycle.py | 31 ++++---- src/compose_farm/operations.py | 118 +++++++++++++++++++++++++++--- tests/test_operations.py | 48 +++++++++--- 4 files changed, 164 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 5060b52..3a8f358 100644 --- a/README.md +++ b/README.md @@ -523,6 +523,8 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a │ --all -a Run on all stacks │ │ --host -H TEXT Filter to stacks on this host │ │ --service -s TEXT Target a specific service within the stack │ +│ --pull Pull images before starting (--pull always) │ +│ --build Build images before starting │ │ --config -c PATH Path to config file │ │ --help -h Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/compose_farm/cli/lifecycle.py b/src/compose_farm/cli/lifecycle.py index 19c7da4..c0e52b2 100644 --- a/src/compose_farm/cli/lifecycle.py +++ b/src/compose_farm/cli/lifecycle.py @@ -30,6 +30,7 @@ from compose_farm.cli.management import _discover_stacks_full from compose_farm.console import MSG_DRY_RUN, console, print_error, print_success from compose_farm.executor import run_compose_on_host, run_on_stacks from compose_farm.operations import ( + build_up_cmd, stop_orphaned_stacks, stop_stray_stacks, up_stacks, @@ -49,6 +50,14 @@ def up( all_stacks: AllOption = False, host: HostOption = None, service: ServiceOption = None, + pull: Annotated[ + bool, + typer.Option("--pull", help="Pull images before starting (--pull always)"), + ] = False, + build: Annotated[ + bool, + typer.Option("--build", help="Build images before starting"), + ] = False, config: ConfigOption = None, ) -> None: """Start stacks (docker compose up -d). Auto-migrates if host changed.""" @@ -58,9 +67,13 @@ def up( print_error("--service requires exactly one stack") raise typer.Exit(1) # For service-level up, use run_on_stacks directly (no migration logic) - results = run_async(run_on_stacks(cfg, stack_list, f"up -d {service}", raw=True)) + results = run_async( + run_on_stacks( + cfg, stack_list, build_up_cmd(pull=pull, build=build, service=service), raw=True + ) + ) else: - results = run_async(up_stacks(cfg, stack_list, raw=True)) + results = run_async(up_stacks(cfg, stack_list, raw=True, pull=pull, build=build)) maybe_regenerate_traefik(cfg, results) report_results(results) @@ -183,18 +196,8 @@ def update( config: ConfigOption = None, ) -> None: """Update stacks. Only recreates containers if images changed.""" - stack_list, cfg = get_stacks(stacks or [], all_stacks, config) - if service: - if len(stack_list) != 1: - print_error("--service requires exactly one stack") - raise typer.Exit(1) - cmd = f"up -d --pull always --build {service}" - else: - cmd = "up -d --pull always --build" - raw = len(stack_list) == 1 - results = run_async(run_on_stacks(cfg, stack_list, cmd, raw=raw)) - maybe_regenerate_traefik(cfg, results) - report_results(results) + # update is just up --pull --build + up(stacks=stacks, all_stacks=all_stacks, service=service, pull=True, build=True, config=config) def _discover_strays(cfg: Config) -> dict[str, list[str]]: diff --git a/src/compose_farm/operations.py b/src/compose_farm/operations.py index d84537e..b9fd1e7 100644 --- a/src/compose_farm/operations.py +++ b/src/compose_farm/operations.py @@ -185,18 +185,37 @@ def _report_preflight_failures( print_error(f" missing device: {dev}") +def build_up_cmd( + *, + pull: bool = False, + build: bool = False, + service: str | None = None, +) -> str: + """Build compose 'up' subcommand with optional flags.""" + parts = ["up", "-d"] + if pull: + parts.append("--pull always") + if build: + parts.append("--build") + if service: + parts.append(service) + return " ".join(parts) + + async def _up_multi_host_stack( cfg: Config, stack: str, prefix: str, *, raw: bool = False, + pull: bool = False, + build: bool = False, ) -> list[CommandResult]: """Start a multi-host stack on all configured hosts.""" host_names = cfg.get_hosts(stack) results: list[CommandResult] = [] compose_path = cfg.get_compose_path(stack) - command = f"docker compose -f {compose_path} up -d" + command = f"docker compose -f {compose_path} {build_up_cmd(pull=pull, build=build)}" # Pre-flight checks on all hosts for host_name in host_names: @@ -269,6 +288,8 @@ async def _up_single_stack( prefix: str, *, raw: bool, + pull: bool = False, + build: bool = False, ) -> CommandResult: """Start a single-host stack with migration support.""" target_host = cfg.get_hosts(stack)[0] @@ -297,7 +318,7 @@ async def _up_single_stack( # Start on target host console.print(f"{prefix} Starting on [magenta]{target_host}[/]...") - up_result = await _run_compose_step(cfg, stack, "up -d", raw=raw) + up_result = await _run_compose_step(cfg, stack, build_up_cmd(pull=pull, build=build), raw=raw) # Update state on success, or rollback on failure if up_result.success: @@ -316,24 +337,101 @@ async def _up_single_stack( return up_result +async def _up_stack_simple( + cfg: Config, + stack: str, + *, + raw: bool = False, + pull: bool = False, + build: bool = False, +) -> CommandResult: + """Start a single-host stack without migration (parallel-safe).""" + target_host = cfg.get_hosts(stack)[0] + + # Pre-flight check + preflight = await check_stack_requirements(cfg, stack, target_host) + if not preflight.ok: + _report_preflight_failures(stack, target_host, preflight) + return CommandResult(stack=stack, exit_code=1, success=False) + + # Run with streaming for parallel output + result = await run_compose(cfg, stack, build_up_cmd(pull=pull, build=build), raw=raw) + if raw: + print() + if result.interrupted: + raise OperationInterruptedError + + # Update state on success + if result.success: + set_stack_host(cfg, stack, target_host) + + return result + + async def up_stacks( cfg: Config, stacks: list[str], *, raw: bool = False, + pull: bool = False, + build: bool = False, ) -> list[CommandResult]: - """Start stacks with automatic migration if host changed.""" + """Start stacks with automatic migration if host changed. + + Stacks without migration run in parallel. Migration stacks run sequentially. + """ + # Categorize stacks + multi_host: list[str] = [] + needs_migration: list[str] = [] + simple: list[str] = [] + + for stack in stacks: + if cfg.is_multi_host(stack): + multi_host.append(stack) + else: + target = cfg.get_hosts(stack)[0] + current = get_stack_host(cfg, stack) + if current and current != target: + needs_migration.append(stack) + else: + simple.append(stack) + results: list[CommandResult] = [] - total = len(stacks) try: - for idx, stack in enumerate(stacks, 1): - prefix = f"[dim][{idx}/{total}][/] [cyan]\\[{stack}][/]" + # Simple stacks: run in parallel (no migration needed) + if simple: + use_raw = raw and len(simple) == 1 + simple_results = await asyncio.gather( + *[ + _up_stack_simple(cfg, stack, raw=use_raw, pull=pull, build=build) + for stack in simple + ] + ) + results.extend(simple_results) + + # Multi-host stacks: run in parallel + if multi_host: + multi_results = await asyncio.gather( + *[ + _up_multi_host_stack( + cfg, stack, f"[cyan]\\[{stack}][/]", raw=raw, pull=pull, build=build + ) + for stack in multi_host + ] + ) + for result_list in multi_results: + results.extend(result_list) + + # Migration stacks: run sequentially for clear output and rollback + if needs_migration: + total = len(needs_migration) + for idx, stack in enumerate(needs_migration, 1): + prefix = f"[dim][{idx}/{total}][/] [cyan]\\[{stack}][/]" + results.append( + await _up_single_stack(cfg, stack, prefix, raw=raw, pull=pull, build=build) + ) - if cfg.is_multi_host(stack): - results.extend(await _up_multi_host_stack(cfg, stack, prefix, raw=raw)) - else: - results.append(await _up_single_stack(cfg, stack, prefix, raw=raw)) except OperationInterruptedError: raise KeyboardInterrupt from None diff --git a/tests/test_operations.py b/tests/test_operations.py index 64c8171..712000e 100644 --- a/tests/test_operations.py +++ b/tests/test_operations.py @@ -14,6 +14,7 @@ from compose_farm.executor import CommandResult from compose_farm.operations import ( _migrate_stack, build_discovery_results, + build_up_cmd, ) @@ -95,22 +96,47 @@ class TestMigrationCommands: assert pull_idx < build_idx +class TestBuildUpCmd: + """Tests for build_up_cmd helper.""" + + def test_basic(self) -> None: + """Basic up command without flags.""" + assert build_up_cmd() == "up -d" + + def test_with_pull(self) -> None: + """Up command with pull flag.""" + assert build_up_cmd(pull=True) == "up -d --pull always" + + def test_with_build(self) -> None: + """Up command with build flag.""" + assert build_up_cmd(build=True) == "up -d --build" + + def test_with_pull_and_build(self) -> None: + """Up command with both flags.""" + assert build_up_cmd(pull=True, build=True) == "up -d --pull always --build" + + def test_with_service(self) -> None: + """Up command targeting a specific service.""" + assert build_up_cmd(service="web") == "up -d web" + + def test_with_all_options(self) -> None: + """Up command with all options.""" + assert ( + build_up_cmd(pull=True, build=True, service="web") == "up -d --pull always --build web" + ) + + class TestUpdateCommandSequence: """Tests for update command sequence.""" - def test_update_command_uses_pull_always_and_build(self) -> None: - """Update command should use --pull always --build flags.""" - # This is a static check of the command sequence in lifecycle.py - # The actual command sequence is defined in the update function - + def test_update_delegates_to_up_with_pull_and_build(self) -> None: + """Update command should delegate to up with pull=True and build=True.""" source = inspect.getsource(lifecycle.update) - # Verify the command uses --pull always (only recreates if image changed) - assert "--pull always" in source - # Verify --build is included for buildable services - assert "--build" in source - # Verify up -d is used - assert "up -d" in source + # Verify update calls up with pull=True and build=True + assert "up(" in source + assert "pull=True" in source + assert "build=True" in source class TestBuildDiscoveryResults: