feat(deploy): add --parallel flag for concurrent image builds

When --parallel is set:
- DOCKER_BUILDKIT=1 is injected into the subprocess environment to
  ensure BuildKit is active regardless of host daemon config
- docker compose build runs first (all images built concurrently)
- docker compose up -d follows without --build (no redundant checks)

Without --parallel the original up --build path is preserved.
--parallel and --no-cache compose correctly (build --no-cache).
This commit is contained in:
2026-04-11 03:46:52 -04:00
parent 5ef48d60be
commit 377ba0410c
2 changed files with 27 additions and 7 deletions

View File

@@ -248,6 +248,7 @@ def deploy(
mutate_interval: Optional[int] = typer.Option(30, "--mutate-interval", help="Automatically rotate services every N minutes"), mutate_interval: Optional[int] = typer.Option(30, "--mutate-interval", help="Automatically rotate services every N minutes"),
dry_run: bool = typer.Option(False, "--dry-run", help="Generate compose file without starting containers"), dry_run: bool = typer.Option(False, "--dry-run", help="Generate compose file without starting containers"),
no_cache: bool = typer.Option(False, "--no-cache", help="Force rebuild all images, ignoring Docker layer cache"), no_cache: bool = typer.Option(False, "--no-cache", help="Force rebuild all images, ignoring Docker layer cache"),
parallel: bool = typer.Option(False, "--parallel", help="Build all images concurrently (enables BuildKit, separates build from up)"),
ipvlan: bool = typer.Option(False, "--ipvlan", help="Use IPvlan L2 instead of MACVLAN (required on WiFi interfaces)"), ipvlan: bool = typer.Option(False, "--ipvlan", help="Use IPvlan L2 instead of MACVLAN (required on WiFi interfaces)"),
config_file: Optional[str] = typer.Option(None, "--config", "-c", help="Path to INI config file"), config_file: Optional[str] = typer.Option(None, "--config", "-c", help="Path to INI config file"),
api: bool = typer.Option(False, "--api", help="Start the FastAPI backend to ingest and serve logs"), api: bool = typer.Option(False, "--api", help="Start the FastAPI backend to ingest and serve logs"),
@@ -379,7 +380,7 @@ def deploy(
) )
from decnet.deployer import deploy as _deploy from decnet.deployer import deploy as _deploy
_deploy(config, dry_run=dry_run, no_cache=no_cache) _deploy(config, dry_run=dry_run, no_cache=no_cache, parallel=parallel)
if mutate_interval is not None and not dry_run: if mutate_interval is not None and not dry_run:
import subprocess # nosec B404 import subprocess # nosec B404

View File

@@ -49,9 +49,11 @@ def _sync_logging_helper(config: DecnetConfig) -> None:
shutil.copy2(_CANONICAL_LOGGING, dest) shutil.copy2(_CANONICAL_LOGGING, dest)
def _compose(*args: str, compose_file: Path = COMPOSE_FILE) -> None: def _compose(*args: str, compose_file: Path = COMPOSE_FILE, env: dict | None = None) -> None:
import os
cmd = ["docker", "compose", "-f", str(compose_file), *args] cmd = ["docker", "compose", "-f", str(compose_file), *args]
subprocess.run(cmd, check=True) # nosec B603 merged = {**os.environ, **(env or {})}
subprocess.run(cmd, check=True, env=merged) # nosec B603
_PERMANENT_ERRORS = ( _PERMANENT_ERRORS = (
@@ -68,12 +70,15 @@ def _compose_with_retry(
compose_file: Path = COMPOSE_FILE, compose_file: Path = COMPOSE_FILE,
retries: int = 3, retries: int = 3,
delay: float = 5.0, delay: float = 5.0,
env: dict | None = None,
) -> None: ) -> None:
"""Run a docker compose command, retrying on transient failures.""" """Run a docker compose command, retrying on transient failures."""
import os
last_exc: subprocess.CalledProcessError | None = None last_exc: subprocess.CalledProcessError | None = None
cmd = ["docker", "compose", "-f", str(compose_file), *args] cmd = ["docker", "compose", "-f", str(compose_file), *args]
merged = {**os.environ, **(env or {})}
for attempt in range(1, retries + 1): for attempt in range(1, retries + 1):
result = subprocess.run(cmd, capture_output=True, text=True) # nosec B603 result = subprocess.run(cmd, capture_output=True, text=True, env=merged) # nosec B603
if result.returncode == 0: if result.returncode == 0:
if result.stdout: if result.stdout:
print(result.stdout, end="") print(result.stdout, end="")
@@ -100,7 +105,7 @@ def _compose_with_retry(
raise last_exc raise last_exc
def deploy(config: DecnetConfig, dry_run: bool = False, no_cache: bool = False) -> None: def deploy(config: DecnetConfig, dry_run: bool = False, no_cache: bool = False, parallel: bool = False) -> None:
client = docker.from_env() client = docker.from_env()
# --- Network setup --- # --- Network setup ---
@@ -145,7 +150,21 @@ def deploy(config: DecnetConfig, dry_run: bool = False, no_cache: bool = False)
save_state(config, compose_path) save_state(config, compose_path)
# --- Bring up --- # --- Bring up ---
# With --parallel: force BuildKit, run build explicitly (so all images are
# built concurrently before any container starts), then up without --build.
# Without --parallel: keep the original up --build path.
build_env = {"DOCKER_BUILDKIT": "1"} if parallel else {}
console.print("[bold cyan]Building images and starting deckies...[/]") console.print("[bold cyan]Building images and starting deckies...[/]")
build_args = ["build"]
if no_cache:
build_args.append("--no-cache")
if parallel:
console.print("[bold cyan]Parallel build enabled — building all images concurrently...[/]")
_compose_with_retry(*build_args, compose_file=compose_path, env=build_env)
_compose_with_retry("up", "-d", compose_file=compose_path, env=build_env)
else:
if no_cache: if no_cache:
_compose_with_retry("build", "--no-cache", compose_file=compose_path) _compose_with_retry("build", "--no-cache", compose_file=compose_path)
_compose_with_retry("up", "--build", "-d", compose_file=compose_path) _compose_with_retry("up", "--build", "-d", compose_file=compose_path)