Compare commits

...

6 Commits

Author SHA1 Message Date
Bas Nijholt
ca2a4dd6d9 cli: Add short command aliases (#148)
* cli: Add short command aliases

Add single and two-letter aliases for frequently used commands:

- a  → apply
- l  → logs
- r  → restart
- u  → update
- p  → pull
- s  → stats
- c  → compose
- rf → refresh
- ck → check
- tf → traefik-file

Aliases are hidden from --help to keep output clean.

* docs: Document command aliases in README
2026-01-05 18:46:57 +01:00
Bas Nijholt
fafdce5736 docs: Clarify Docker Compose vs Compose Farm commands (#147)
* docs: Clarify Docker Compose vs Compose Farm commands

Split the Usage section into two tables:
- Docker Compose Commands: wrappers with multi-host additions
- Compose Farm Commands: orchestration Docker Compose can't do

Also update the `update` command docstring to clarify it's
a shorthand for `up --pull --build`.

* chore(docs): update TOC

* docs: Add command type distinction to commands.md

Explain that commands are either Docker Compose wrappers with
multi-host superpowers, or Compose Farm originals for orchestration.
Also update `update` description to clarify it's a shorthand.

* Update README.md
2026-01-05 18:37:41 +01:00
Bas Nijholt
6436becff9 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.
2026-01-05 15:55:00 +01:00
Bas Nijholt
3460d8a3ea restart: Match Docker Compose semantics (#145)
* restart: Match Docker Compose semantics

Change `cf restart` from doing `down + up` to using `docker compose
restart`, matching the Docker Compose command behavior.

This provides command naming parity with Docker Compose. Users who want
the old behavior can use `cf down mystack && cf up mystack`.

- Update restart implementation to use `docker compose restart`
- Remove traefik regeneration from restart (no longer recreates containers)
- Update all documentation and help text
- Remove restart from self-update SSH handling (no longer involves down)

* web: Clarify Update tooltip uses 'recreate' not 'restart'

Avoid confusion now that 'restart' means something different.

* web: Fix Update All tooltip to use 'recreates'
2026-01-05 14:29:03 +01:00
Bas Nijholt
8dabc27272 update: Only restart containers when images change (#143)
* update: Only restart containers when images change

Use `up -d --pull always --build` instead of separate pull/build/down/up
steps. This avoids unnecessary container restarts when images haven't
changed.

* Update README.md

* docs: Update update command description across all docs

Reflect new behavior: only recreates containers if images changed.

* Update README.md

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-05 10:06:45 +01:00
Bas Nijholt
5e08f1d712 web: Exclude web stack from Update All button (#142) 2026-01-04 19:56:41 +01:00
23 changed files with 272 additions and 206 deletions

View File

@@ -137,8 +137,8 @@ CLI available as `cf` or `compose-farm`.
| `down` | Stop stacks (`docker compose down`). Use `--orphaned` to stop stacks removed from config |
| `stop` | Stop services without removing containers (`docker compose stop`) |
| `pull` | Pull latest images |
| `restart` | `down` + `up -d` |
| `update` | `pull` + `build` + `down` + `up -d` |
| `restart` | Restart running containers (`docker compose restart`) |
| `update` | Pull, build, recreate only if changed (`up -d --pull always --build`) |
| `apply` | Make reality match config: migrate stacks + stop orphans. Use `--dry-run` to preview |
| `compose` | Run any docker compose command on a stack (passthrough) |
| `logs` | Show stack logs |

View File

@@ -51,6 +51,9 @@ A minimal CLI tool to run Docker Compose commands across multiple hosts via SSH.
- [Multi-Host Stacks](#multi-host-stacks)
- [Config Command](#config-command)
- [Usage](#usage)
- [Docker Compose Commands](#docker-compose-commands)
- [Compose Farm Commands](#compose-farm-commands)
- [Aliases](#aliases)
- [CLI `--help` Output](#cli---help-output)
- [Auto-Migration](#auto-migration)
- [Traefik Multihost Ingress (File Provider)](#traefik-multihost-ingress-file-provider)
@@ -363,24 +366,47 @@ Use `cf config init` to get started with a fully documented template.
The CLI is available as both `compose-farm` and the shorter `cf` alias.
### Docker Compose Commands
These wrap `docker compose` with multi-host superpowers:
| Command | Wraps | Compose Farm Additions |
|---------|-------|------------------------|
| `cf up` | `up -d` | `--all`, `--host`, parallel execution, auto-migration |
| `cf down` | `down` | `--all`, `--host`, `--orphaned`, state tracking |
| `cf stop` | `stop` | `--all`, `--service` |
| `cf restart` | `restart` | `--all`, `--service` |
| `cf pull` | `pull` | `--all`, `--service`, parallel execution |
| `cf logs` | `logs` | `--all`, `--host`, multi-stack output |
| `cf ps` | `ps` | `--all`, `--host`, unified cross-host view |
| `cf compose` | any | passthrough for commands not listed above |
### Compose Farm Commands
Multi-host orchestration that Docker Compose can't do:
| Command | Description |
|---------|-------------|
| **`cf apply`** | **Make reality match config (start + migrate + stop orphans)** |
| `cf up <stack>` | Start stack (auto-migrates if host changed) |
| `cf down <stack>` | Stop and remove stack containers |
| `cf stop <stack>` | Stop stack without removing containers |
| `cf restart <stack>` | down + up |
| `cf update <stack>` | pull + build + down + up |
| `cf pull <stack>` | Pull latest images |
| `cf logs -f <stack>` | Follow logs |
| `cf ps` | Show status of all stacks |
| `cf refresh` | Update state from running stacks |
| **`cf apply`** | **Reconcile: start missing, migrate moved, stop orphans** |
| `cf update` | Shorthand for `up --pull --build` |
| `cf refresh` | Sync state from what's actually running |
| `cf check` | Validate config, mounts, networks |
| `cf init-network` | Create Docker network on hosts |
| `cf init-network` | Create Docker network on all hosts |
| `cf traefik-file` | Generate Traefik file-provider config |
| `cf config <cmd>` | Manage config files (init, show, path, validate, edit, symlink) |
| `cf config` | Manage config files (init, show, validate, edit, symlink) |
| `cf ssh` | Manage SSH keys (setup, status, keygen) |
All commands support `--all` to operate on all stacks.
### Aliases
Short aliases for frequently used commands:
| Alias | Command | Alias | Command |
|-------|---------|-------|---------|
| `cf a` | `apply` | `cf s` | `stats` |
| `cf l` | `logs` | `cf c` | `compose` |
| `cf r` | `restart` | `cf rf` | `refresh` |
| `cf u` | `update` | `cf ck` | `check` |
| `cf p` | `pull` | `cf tf` | `traefik-file` |
Each command replaces: look up host → SSH → find compose file → run `ssh host "cd /opt/compose/plex && docker compose up -d"`.
@@ -400,10 +426,10 @@ cf down --orphaned # stop stacks removed from config
# Pull latest images
cf pull --all
# Restart (down + up)
# Restart running containers
cf restart plex
# Update (pull + build + down + up) - the end-to-end update command
# Update (pull + build, only recreates containers if images changed)
cf update --all
# Update state from reality (discovers running stacks + captures digests)
@@ -473,10 +499,9 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
│ stop Stop services without removing containers (docker compose │
│ stop). │
│ pull Pull latest images (docker compose pull). │
│ restart Restart stacks (down + up). With --service, restarts just
that service.
update Update stacks (pull + build + down + up). With --service,
│ updates just that service. │
│ restart Restart running containers (docker compose restart).
update Update stacks (pull + build + up). Shorthand for 'up --pull
--build'.
│ apply Make reality match config (start, migrate, stop │
│ strays/orphans as needed). │
│ compose Run any docker compose command on a stack. │
@@ -525,6 +550,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. │
╰──────────────────────────────────────────────────────────────────────────────╯
@@ -659,7 +686,7 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Usage: cf restart [OPTIONS] [STACKS]...
Restart stacks (down + up). With --service, restarts just that service.
Restart running containers (docker compose restart).
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ stacks [STACKS]... Stacks to operate on │
@@ -694,8 +721,7 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Usage: cf update [OPTIONS] [STACKS]...
Update stacks (pull + build + down + up). With --service, updates just that
service.
Update stacks (pull + build + up). Shorthand for 'up --pull --build'.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ stacks [STACKS]... Stacks to operate on │
@@ -1283,12 +1309,12 @@ published ports.
**Auto-regeneration**
To automatically regenerate the Traefik config after `up`, `down`, `restart`, or `update`,
To automatically regenerate the Traefik config after `up`, `down`, or `update`,
add `traefik_file` to your config:
```yaml
compose_dir: /opt/compose
traefik_file: /opt/traefik/dynamic.d/compose-farm.yml # auto-regenerate on up/down/restart/update
traefik_file: /opt/traefik/dynamic.d/compose-farm.yml # auto-regenerate on up/down/update
traefik_stack: traefik # skip stacks on same host (docker provider handles them)
hosts:

View File

@@ -3,7 +3,7 @@
compose_dir: /opt/compose
# Optional: Auto-regenerate Traefik file-provider config after up/down/restart/update
# Optional: Auto-regenerate Traefik file-provider config after up/down/update
traefik_file: /opt/traefik/dynamic.d/compose-farm.yml
traefik_stack: traefik # Skip stacks on same host (docker provider handles them)

View File

@@ -8,14 +8,16 @@ The Compose Farm CLI is available as both `compose-farm` and the shorter alias `
## Command Overview
Commands are either **Docker Compose wrappers** (`up`, `down`, `stop`, `restart`, `pull`, `logs`, `ps`, `compose`) with multi-host superpowers, or **Compose Farm originals** (`apply`, `update`, `refresh`, `check`) for orchestration Docker Compose can't do.
| Category | Command | Description |
|----------|---------|-------------|
| **Lifecycle** | `apply` | Make reality match config |
| | `up` | Start stacks |
| | `down` | Stop stacks |
| | `stop` | Stop services without removing containers |
| | `restart` | Restart stacks (down + up) |
| | `update` | Update stacks (pull + build + down + up) |
| | `restart` | Restart running containers |
| | `update` | Shorthand for `up --pull --build` |
| | `pull` | Pull latest images |
| | `compose` | Run any docker compose command |
| **Monitoring** | `ps` | Show stack status |
@@ -197,7 +199,7 @@ cf stop immich --service database
### cf restart
Restart stacks (down + up). With `--service`, restarts just that service.
Restart running containers (`docker compose restart`). With `--service`, restarts just that service.
```bash
cf restart [OPTIONS] [STACKS]...
@@ -225,7 +227,7 @@ cf restart immich --service database
### cf update
Update stacks (pull + build + down + up). With `--service`, updates just that service.
Update stacks (pull + build + up). Shorthand for `up --pull --build`. With `--service`, updates just that service.
<video autoplay loop muted playsinline>
<source src="/assets/update.webm" type="video/webm">

View File

@@ -107,7 +107,7 @@ Supported compose file names (checked in order):
### traefik_file
Path to auto-generated Traefik file-provider config. When set, Compose Farm regenerates this file after `up`, `down`, `restart`, and `update` commands.
Path to auto-generated Traefik file-provider config. When set, Compose Farm regenerates this file after `up`, `down`, and `update` commands.
```yaml
traefik_file: /opt/traefik/dynamic.d/compose-farm.yml

View File

@@ -1,5 +1,5 @@
# Update Demo
# Shows updating stacks (pull + build + down + up)
# Shows updating stacks (only recreates containers if images changed)
Output docs/assets/update.gif
Output docs/assets/update.webm

View File

@@ -329,7 +329,7 @@ cf apply
```bash
cf update --all
# Runs: pull + build + down + up for each stack
# Only recreates containers if images changed
```
## Next Steps

View File

@@ -139,7 +139,6 @@ stacks:
With `traefik_file` set, these commands auto-regenerate the config:
- `cf up`
- `cf down`
- `cf restart`
- `cf update`
- `cf apply`

View File

@@ -168,4 +168,4 @@ traefik_file: /opt/stacks/traefik/dynamic.d/compose-farm.yml
traefik_stack: traefik
```
With `traefik_file` configured, compose-farm automatically regenerates the config after `up`, `down`, `restart`, and `update` commands.
With `traefik_file` configured, compose-farm automatically regenerates the config after `up`, `down`, and `update` commands.

View File

@@ -5,7 +5,7 @@
compose_dir: /opt/stacks/compose-farm/examples
# Auto-regenerate Traefik file-provider config after up/down/restart/update
# Auto-regenerate Traefik file-provider config after up/down/update
traefik_file: /opt/stacks/compose-farm/examples/traefik/dynamic.d/compose-farm.yml
traefik_stack: traefik # Skip Traefik's host in file-provider (docker provider handles it)

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio
import contextlib
import os
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, TypeVar
@@ -69,21 +68,6 @@ ServiceOption = Annotated[
_MISSING_PATH_PREVIEW_LIMIT = 2
_STATS_PREVIEW_LIMIT = 3 # Max number of pending migrations to show by name
# Environment variable to identify the web stack (for self-update ordering)
CF_WEB_STACK = os.environ.get("CF_WEB_STACK", "")
def sort_web_stack_last(stacks: list[str]) -> list[str]:
"""Move the web stack to the end of the list.
When updating all stacks, the web UI stack (compose-farm) should be updated
last. Otherwise, the container restarts mid-process and cancels remaining
updates. The CF_WEB_STACK env var identifies the web stack.
"""
if CF_WEB_STACK and CF_WEB_STACK in stacks:
return [s for s in stacks if s != CF_WEB_STACK] + [CF_WEB_STACK]
return stacks
def format_host(host: str | list[str]) -> str:
"""Format a host value for display."""

View File

@@ -23,14 +23,14 @@ from compose_farm.cli.common import (
maybe_regenerate_traefik,
report_results,
run_async,
sort_web_stack_last,
validate_host_for_stack,
validate_stacks,
)
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, run_sequential_on_stacks
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,
@@ -50,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."""
@@ -59,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)
@@ -162,21 +174,17 @@ def restart(
service: ServiceOption = None,
config: ConfigOption = None,
) -> None:
"""Restart stacks (down + up). With --service, restarts just that service."""
"""Restart running containers (docker compose restart)."""
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)
# For service-level restart, use docker compose restart (more efficient)
raw = True
results = run_async(run_on_stacks(cfg, stack_list, f"restart {service}", raw=raw))
cmd = f"restart {service}"
else:
# Sort web stack last to avoid self-restart canceling remaining restarts
stack_list = sort_web_stack_last(stack_list)
raw = len(stack_list) == 1
results = run_async(run_sequential_on_stacks(cfg, stack_list, ["down", "up -d"], raw=raw))
maybe_regenerate_traefik(cfg, results)
cmd = "restart"
raw = len(stack_list) == 1
results = run_async(run_on_stacks(cfg, stack_list, cmd, raw=raw))
report_results(results)
@@ -187,38 +195,8 @@ def update(
service: ServiceOption = None,
config: ConfigOption = None,
) -> None:
"""Update stacks (pull + build + down + up). With --service, updates just that service."""
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)
# For service-level update: pull + build + stop + up (stop instead of down)
raw = True
results = run_async(
run_sequential_on_stacks(
cfg,
stack_list,
[
f"pull --ignore-buildable {service}",
f"build {service}",
f"stop {service}",
f"up -d {service}",
],
raw=raw,
)
)
else:
# Sort web stack last to avoid self-restart canceling remaining updates
stack_list = sort_web_stack_last(stack_list)
raw = len(stack_list) == 1
results = run_async(
run_sequential_on_stacks(
cfg, stack_list, ["pull --ignore-buildable", "build", "down", "up -d"], raw=raw
)
)
maybe_regenerate_traefik(cfg, results)
report_results(results)
"""Update stacks (pull + build + up). Shorthand for '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]]:
@@ -333,11 +311,6 @@ def apply( # noqa: C901, PLR0912, PLR0915 (multi-phase reconciliation needs the
console.print(f"\n{MSG_DRY_RUN}")
return
# Sort web stack last in each phase to avoid self-restart canceling remaining work
migrations = sort_web_stack_last(migrations)
missing = sort_web_stack_last(missing)
to_refresh = sort_web_stack_last(to_refresh)
# Execute changes
console.print()
all_results = []
@@ -433,5 +406,9 @@ def compose(
raise typer.Exit(result.exit_code)
# Alias: cf a = cf apply
app.command("a", hidden=True)(apply)
# Aliases (hidden from help, shown in --help as "Aliases: ...")
app.command("a", hidden=True)(apply) # cf a = cf apply
app.command("r", hidden=True)(restart) # cf r = cf restart
app.command("u", hidden=True)(update) # cf u = cf update
app.command("p", hidden=True)(pull) # cf p = cf pull
app.command("c", hidden=True)(compose) # cf c = cf compose

View File

@@ -659,3 +659,9 @@ def init_network(
failed = [r for r in results if not r.success]
if failed:
raise typer.Exit(1)
# Aliases (hidden from help)
app.command("rf", hidden=True)(refresh) # cf rf = cf refresh
app.command("ck", hidden=True)(check) # cf ck = cf check
app.command("tf", hidden=True)(traefik_file) # cf tf = cf traefik-file

View File

@@ -201,3 +201,8 @@ def stats(
console.print()
console.print(_build_summary_table(cfg, state, pending))
# Aliases (hidden from help)
app.command("l", hidden=True)(logs) # cf l = cf logs
app.command("s", hidden=True)(stats) # cf s = cf stats

View File

@@ -76,7 +76,7 @@ stacks:
# traefik_file: (optional) Auto-generate Traefik file-provider config
# ------------------------------------------------------------------------------
# When set, compose-farm automatically regenerates this file after
# up/down/restart/update commands. Traefik watches this file for changes.
# up/down/update commands. Traefik watches this file for changes.
#
# traefik_file: /opt/compose/traefik/dynamic.d/compose-farm.yml

View File

@@ -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

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import os
import uuid
from typing import TYPE_CHECKING, Any
@@ -14,6 +15,9 @@ if TYPE_CHECKING:
from compose_farm.web.deps import get_config
from compose_farm.web.streaming import run_cli_streaming, run_compose_streaming, tasks
# Environment variable to identify the web stack (for exclusion from bulk updates)
CF_WEB_STACK = os.environ.get("CF_WEB_STACK", "")
router = APIRouter(tags=["actions"])
# Store task references to prevent garbage collection
@@ -96,7 +100,15 @@ async def pull_all() -> dict[str, Any]:
@router.post("/update-all")
async def update_all() -> dict[str, Any]:
"""Update all stacks (pull + build + down + up)."""
"""Update all stacks, excluding the web stack. Only recreates if images changed.
The web stack is excluded to prevent the UI from shutting down mid-operation.
Use 'cf update <web-stack>' manually to update the web UI.
"""
config = get_config()
task_id = _start_task(lambda tid: run_cli_streaming(config, ["update", "--all"], tid))
return {"task_id": task_id, "command": "update --all"}
# Get all stacks except the web stack to avoid self-shutdown
stacks = [s for s in config.stacks if s != CF_WEB_STACK]
if not stacks:
return {"task_id": "", "command": "update (no stacks)", "skipped": True}
task_id = _start_task(lambda tid: run_cli_streaming(config, ["update", *stacks], tid))
return {"task_id": task_id, "command": f"update {' '.join(stacks)}"}

View File

@@ -608,7 +608,7 @@ function playFabIntro() {
cmd('action', 'Apply', 'Make reality match config', dashboardAction('apply'), icons.check),
cmd('action', 'Refresh', 'Update state from reality', dashboardAction('refresh'), icons.refresh_cw),
cmd('action', 'Pull All', 'Pull latest images for all stacks', dashboardAction('pull-all'), icons.cloud_download),
cmd('action', 'Update All', 'Update all stacks', dashboardAction('update-all'), icons.refresh_cw),
cmd('action', 'Update All', 'Update all stacks except web', dashboardAction('update-all'), icons.refresh_cw),
cmd('app', 'Theme', 'Change color theme', openThemePicker, icons.palette),
cmd('app', 'Dashboard', 'Go to dashboard', nav('/'), icons.home),
cmd('app', 'Live Stats', 'View all containers across hosts', nav('/live-stats'), icons.box),
@@ -628,7 +628,7 @@ function playFabIntro() {
stackCmd('Down', 'Stop', 'down', icons.square),
stackCmd('Restart', 'Restart', 'restart', icons.rotate_cw),
stackCmd('Pull', 'Pull', 'pull', icons.cloud_download),
stackCmd('Update', 'Pull + restart', 'update', icons.refresh_cw),
stackCmd('Update', 'Pull + recreate', 'update', icons.refresh_cw),
stackCmd('Logs', 'View logs for', 'logs', icons.file_text),
);

View File

@@ -103,8 +103,8 @@ def _is_self_update(stack: str, command: str) -> bool:
"""
if not CF_WEB_STACK or stack != CF_WEB_STACK:
return False
# Commands that involve 'down' need SSH: update, restart, down
return command in ("update", "restart", "down")
# Commands that involve 'down' need SSH: update, down
return command in ("update", "down")
async def _run_cli_via_ssh(

View File

@@ -18,7 +18,7 @@
{{ action_btn("Apply", "/api/apply", "primary", "Make reality match config", check()) }}
{{ action_btn("Refresh", "/api/refresh", "outline", "Update state from reality", refresh_cw()) }}
{{ action_btn("Pull All", "/api/pull-all", "outline", "Pull latest images for all stacks", cloud_download()) }}
{{ action_btn("Update All", "/api/update-all", "outline", "Update all stacks (pull + build + down + up)", rotate_cw()) }}
{{ action_btn("Update All", "/api/update-all", "outline", "Update all stacks except web (only recreates if changed)", rotate_cw()) }}
<div class="tooltip" data-tip="Save compose-farm.yaml config file"><button id="save-config-btn" class="btn btn-outline">{{ save() }} Save Config</button></div>
</div>

View File

@@ -22,8 +22,8 @@
<!-- Lifecycle -->
{{ action_btn("Up", "/api/stack/" ~ name ~ "/up", "primary", "Start stack (docker compose up -d)", play()) }}
{{ action_btn("Down", "/api/stack/" ~ name ~ "/down", "outline", "Stop stack (docker compose down)", square()) }}
{{ action_btn("Restart", "/api/stack/" ~ name ~ "/restart", "secondary", "Restart stack (down + up)", rotate_cw()) }}
{{ action_btn("Update", "/api/stack/" ~ name ~ "/update", "accent", "Update to latest (pull + build + down + up)", download()) }}
{{ action_btn("Restart", "/api/stack/" ~ name ~ "/restart", "secondary", "Restart running containers", rotate_cw()) }}
{{ action_btn("Update", "/api/stack/" ~ name ~ "/update", "accent", "Update to latest (only recreates if changed)", download()) }}
<div class="divider divider-horizontal mx-0"></div>

View File

@@ -437,71 +437,3 @@ class TestDownOrphaned:
)
assert exc_info.value.exit_code == 1
class TestSortWebStackLast:
"""Tests for the sort_web_stack_last helper."""
def test_no_web_stack_env(self) -> None:
"""When CF_WEB_STACK is not set, list is unchanged."""
from compose_farm.cli import common
original = common.CF_WEB_STACK
try:
common.CF_WEB_STACK = ""
stacks = ["a", "b", "c"]
result = common.sort_web_stack_last(stacks)
assert result == ["a", "b", "c"]
finally:
common.CF_WEB_STACK = original
def test_web_stack_not_in_list(self) -> None:
"""When web stack is not in list, list is unchanged."""
from compose_farm.cli import common
original = common.CF_WEB_STACK
try:
common.CF_WEB_STACK = "webstack"
stacks = ["a", "b", "c"]
result = common.sort_web_stack_last(stacks)
assert result == ["a", "b", "c"]
finally:
common.CF_WEB_STACK = original
def test_web_stack_moved_to_end(self) -> None:
"""When web stack is in list, it's moved to the end."""
from compose_farm.cli import common
original = common.CF_WEB_STACK
try:
common.CF_WEB_STACK = "webstack"
stacks = ["a", "webstack", "b", "c"]
result = common.sort_web_stack_last(stacks)
assert result == ["a", "b", "c", "webstack"]
finally:
common.CF_WEB_STACK = original
def test_web_stack_already_last(self) -> None:
"""When web stack is already last, list is unchanged."""
from compose_farm.cli import common
original = common.CF_WEB_STACK
try:
common.CF_WEB_STACK = "webstack"
stacks = ["a", "b", "webstack"]
result = common.sort_web_stack_last(stacks)
assert result == ["a", "b", "webstack"]
finally:
common.CF_WEB_STACK = original
def test_empty_list(self) -> None:
"""Empty list returns empty list."""
from compose_farm.cli import common
original = common.CF_WEB_STACK
try:
common.CF_WEB_STACK = "webstack"
result = common.sort_web_stack_last([])
assert result == []
finally:
common.CF_WEB_STACK = original

View File

@@ -14,6 +14,7 @@ from compose_farm.executor import CommandResult
from compose_farm.operations import (
_migrate_stack,
build_discovery_results,
build_up_cmd,
)
@@ -95,23 +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_sequence_includes_build(self) -> None:
"""Update command should use pull --ignore-buildable and build."""
# 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 sequence includes pull --ignore-buildable
assert "pull --ignore-buildable" in source
# Verify build is included
assert '"build"' in source or "'build'" in source
# Verify the sequence is pull, build, down, up
assert "down" in source
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: