mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 06:03:25 +00:00
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.
This commit is contained in:
@@ -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. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user