mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 14:13:26 +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 │
|
│ --all -a Run on all stacks │
|
||||||
│ --host -H TEXT Filter to stacks on this host │
|
│ --host -H TEXT Filter to stacks on this host │
|
||||||
│ --service -s TEXT Target a specific service within the stack │
|
│ --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 │
|
│ --config -c PATH Path to config file │
|
||||||
│ --help -h Show this message and exit. │
|
│ --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.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.executor import run_compose_on_host, run_on_stacks
|
||||||
from compose_farm.operations import (
|
from compose_farm.operations import (
|
||||||
|
build_up_cmd,
|
||||||
stop_orphaned_stacks,
|
stop_orphaned_stacks,
|
||||||
stop_stray_stacks,
|
stop_stray_stacks,
|
||||||
up_stacks,
|
up_stacks,
|
||||||
@@ -49,6 +50,14 @@ def up(
|
|||||||
all_stacks: AllOption = False,
|
all_stacks: AllOption = False,
|
||||||
host: HostOption = None,
|
host: HostOption = None,
|
||||||
service: ServiceOption = 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,
|
config: ConfigOption = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Start stacks (docker compose up -d). Auto-migrates if host changed."""
|
"""Start stacks (docker compose up -d). Auto-migrates if host changed."""
|
||||||
@@ -58,9 +67,13 @@ def up(
|
|||||||
print_error("--service requires exactly one stack")
|
print_error("--service requires exactly one stack")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
# For service-level up, use run_on_stacks directly (no migration logic)
|
# 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:
|
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)
|
maybe_regenerate_traefik(cfg, results)
|
||||||
report_results(results)
|
report_results(results)
|
||||||
|
|
||||||
@@ -183,18 +196,8 @@ def update(
|
|||||||
config: ConfigOption = None,
|
config: ConfigOption = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update stacks. Only recreates containers if images changed."""
|
"""Update stacks. Only recreates containers if images changed."""
|
||||||
stack_list, cfg = get_stacks(stacks or [], all_stacks, config)
|
# update is just up --pull --build
|
||||||
if service:
|
up(stacks=stacks, all_stacks=all_stacks, service=service, pull=True, build=True, config=config)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def _discover_strays(cfg: Config) -> dict[str, list[str]]:
|
def _discover_strays(cfg: Config) -> dict[str, list[str]]:
|
||||||
|
|||||||
@@ -185,18 +185,37 @@ def _report_preflight_failures(
|
|||||||
print_error(f" missing device: {dev}")
|
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(
|
async def _up_multi_host_stack(
|
||||||
cfg: Config,
|
cfg: Config,
|
||||||
stack: str,
|
stack: str,
|
||||||
prefix: str,
|
prefix: str,
|
||||||
*,
|
*,
|
||||||
raw: bool = False,
|
raw: bool = False,
|
||||||
|
pull: bool = False,
|
||||||
|
build: bool = False,
|
||||||
) -> list[CommandResult]:
|
) -> list[CommandResult]:
|
||||||
"""Start a multi-host stack on all configured hosts."""
|
"""Start a multi-host stack on all configured hosts."""
|
||||||
host_names = cfg.get_hosts(stack)
|
host_names = cfg.get_hosts(stack)
|
||||||
results: list[CommandResult] = []
|
results: list[CommandResult] = []
|
||||||
compose_path = cfg.get_compose_path(stack)
|
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
|
# Pre-flight checks on all hosts
|
||||||
for host_name in host_names:
|
for host_name in host_names:
|
||||||
@@ -269,6 +288,8 @@ async def _up_single_stack(
|
|||||||
prefix: str,
|
prefix: str,
|
||||||
*,
|
*,
|
||||||
raw: bool,
|
raw: bool,
|
||||||
|
pull: bool = False,
|
||||||
|
build: bool = False,
|
||||||
) -> CommandResult:
|
) -> CommandResult:
|
||||||
"""Start a single-host stack with migration support."""
|
"""Start a single-host stack with migration support."""
|
||||||
target_host = cfg.get_hosts(stack)[0]
|
target_host = cfg.get_hosts(stack)[0]
|
||||||
@@ -297,7 +318,7 @@ async def _up_single_stack(
|
|||||||
|
|
||||||
# Start on target host
|
# Start on target host
|
||||||
console.print(f"{prefix} Starting on [magenta]{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
|
# Update state on success, or rollback on failure
|
||||||
if up_result.success:
|
if up_result.success:
|
||||||
@@ -316,24 +337,101 @@ async def _up_single_stack(
|
|||||||
return up_result
|
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(
|
async def up_stacks(
|
||||||
cfg: Config,
|
cfg: Config,
|
||||||
stacks: list[str],
|
stacks: list[str],
|
||||||
*,
|
*,
|
||||||
raw: bool = False,
|
raw: bool = False,
|
||||||
|
pull: bool = False,
|
||||||
|
build: bool = False,
|
||||||
) -> list[CommandResult]:
|
) -> 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] = []
|
results: list[CommandResult] = []
|
||||||
total = len(stacks)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for idx, stack in enumerate(stacks, 1):
|
# Simple stacks: run in parallel (no migration needed)
|
||||||
prefix = f"[dim][{idx}/{total}][/] [cyan]\\[{stack}][/]"
|
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:
|
except OperationInterruptedError:
|
||||||
raise KeyboardInterrupt from None
|
raise KeyboardInterrupt from None
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from compose_farm.executor import CommandResult
|
|||||||
from compose_farm.operations import (
|
from compose_farm.operations import (
|
||||||
_migrate_stack,
|
_migrate_stack,
|
||||||
build_discovery_results,
|
build_discovery_results,
|
||||||
|
build_up_cmd,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -95,22 +96,47 @@ class TestMigrationCommands:
|
|||||||
assert pull_idx < build_idx
|
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:
|
class TestUpdateCommandSequence:
|
||||||
"""Tests for update command sequence."""
|
"""Tests for update command sequence."""
|
||||||
|
|
||||||
def test_update_command_uses_pull_always_and_build(self) -> None:
|
def test_update_delegates_to_up_with_pull_and_build(self) -> None:
|
||||||
"""Update command should use --pull always --build flags."""
|
"""Update command should delegate to up with pull=True and build=True."""
|
||||||
# This is a static check of the command sequence in lifecycle.py
|
|
||||||
# The actual command sequence is defined in the update function
|
|
||||||
|
|
||||||
source = inspect.getsource(lifecycle.update)
|
source = inspect.getsource(lifecycle.update)
|
||||||
|
|
||||||
# Verify the command uses --pull always (only recreates if image changed)
|
# Verify update calls up with pull=True and build=True
|
||||||
assert "--pull always" in source
|
assert "up(" in source
|
||||||
# Verify --build is included for buildable services
|
assert "pull=True" in source
|
||||||
assert "--build" in source
|
assert "build=True" in source
|
||||||
# Verify up -d is used
|
|
||||||
assert "up -d" in source
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildDiscoveryResults:
|
class TestBuildDiscoveryResults:
|
||||||
|
|||||||
Reference in New Issue
Block a user