From 59b797a89dec073f348ee8b20296236547161b5d Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 20 Dec 2025 20:56:48 -0800 Subject: [PATCH] feat: add service-level commands with --service flag (#91) Add support for targeting specific services within a stack: CLI: - New `stop` command for stopping services without removing containers - Add `--service` / `-s` flag to: up, pull, restart, update, stop, logs, ps - Service flag requires exactly one stack to be specified Web API: - Add `stop` to allowed stack commands - New endpoint: POST /api/stack/{name}/service/{service}/{command} - Supports: logs, pull, restart, up, stop Web UI: - Add action buttons to container rows: logs, restart, stop, shell - Add rotate_ccw and scroll_text icons for new buttons --- CLAUDE.md | 1 + README.md | 111 +++++++++++++----- src/compose_farm/cli/common.py | 4 + src/compose_farm/cli/lifecycle.py | 84 +++++++++++-- src/compose_farm/cli/monitoring.py | 19 ++- src/compose_farm/web/routes/actions.py | 24 +++- .../web/templates/partials/containers.html | 35 +++++- .../web/templates/partials/icons.html | 12 ++ 8 files changed, 237 insertions(+), 53 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fab2fb7..f90856c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,6 +116,7 @@ CLI available as `cf` or `compose-farm`. |---------|-------------| | `up` | Start stacks (`docker compose up -d`), auto-migrates if host changed | | `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` | diff --git a/README.md b/README.md index 6b51b9d..339a4b7 100644 --- a/README.md +++ b/README.md @@ -332,7 +332,8 @@ The CLI is available as both `compose-farm` and the shorter `cf` alias. |---------|-------------| | **`cf apply`** | **Make reality match config (start + migrate + stop orphans)** | | `cf up ` | Start stack (auto-migrates if host changed) | -| `cf down ` | Stop stack | +| `cf down ` | Stop and remove stack containers | +| `cf stop ` | Stop stack without removing containers | | `cf restart ` | down + up | | `cf update ` | pull + build + down + up | | `cf pull ` | Pull latest images | @@ -425,9 +426,13 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a │ up Start stacks (docker compose up -d). Auto-migrates if host │ │ changed. │ │ down Stop stacks (docker compose down). │ +│ stop Stop services without removing containers (docker compose │ +│ stop). │ │ pull Pull latest images (docker compose pull). │ -│ restart Restart stacks (down + up). │ -│ update Update stacks (pull + build + down + up). │ +│ restart Restart stacks (down + up). With --service, restarts just │ +│ that service. │ +│ update Update stacks (pull + build + down + up). With --service, │ +│ updates just that service. │ │ apply Make reality match config (start, migrate, stop as needed). │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Configuration ──────────────────────────────────────────────────────────────╮ @@ -440,7 +445,8 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a │ ssh Manage SSH keys for passwordless authentication. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Monitoring ─────────────────────────────────────────────────────────────────╮ -│ logs Show stack logs. │ +│ logs Show stack logs. With --service, shows logs for just that │ +│ service. │ │ ps Show status of stacks. │ │ stats Show overview statistics for hosts and stacks. │ ╰──────────────────────────────────────────────────────────────────────────────╯ @@ -479,10 +485,11 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a │ stacks [STACKS]... Stacks to operate on │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ -│ --all -a Run on all stacks │ -│ --host -H TEXT Filter to stacks on this host │ -│ --config -c PATH Path to config file │ -│ --help -h Show this message and exit. │ +│ --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 │ +│ --config -c PATH Path to config file │ +│ --help -h Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ``` @@ -528,6 +535,41 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a +
+See the output of cf stop --help + + + + + + + + + + + +```yaml + + Usage: cf stop [OPTIONS] [STACKS]... + + Stop services without removing containers (docker compose stop). + +╭─ Arguments ──────────────────────────────────────────────────────────────────╮ +│ stacks [STACKS]... Stacks to operate on │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────╮ +│ --all -a Run on all stacks │ +│ --service -s TEXT Target a specific service within the stack │ +│ --config -c PATH Path to config file │ +│ --help -h Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────╯ + +``` + + + +
+
See the output of cf pull --help @@ -551,9 +593,10 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a │ stacks [STACKS]... Stacks to operate on │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ -│ --all -a Run on all stacks │ -│ --config -c PATH Path to config file │ -│ --help -h Show this message and exit. │ +│ --all -a Run on all stacks │ +│ --service -s TEXT Target a specific service within the stack │ +│ --config -c PATH Path to config file │ +│ --help -h Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ``` @@ -579,15 +622,16 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a Usage: cf restart [OPTIONS] [STACKS]... - Restart stacks (down + up). + Restart stacks (down + up). With --service, restarts just that service. ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ │ stacks [STACKS]... Stacks to operate on │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ -│ --all -a Run on all stacks │ -│ --config -c PATH Path to config file │ -│ --help -h Show this message and exit. │ +│ --all -a Run on all stacks │ +│ --service -s TEXT Target a specific service within the stack │ +│ --config -c PATH Path to config file │ +│ --help -h Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ``` @@ -613,15 +657,17 @@ 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). + Update stacks (pull + build + down + up). With --service, updates just that + service. ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ │ stacks [STACKS]... Stacks to operate on │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ -│ --all -a Run on all stacks │ -│ --config -c PATH Path to config file │ -│ --help -h Show this message and exit. │ +│ --all -a Run on all stacks │ +│ --service -s TEXT Target a specific service within the stack │ +│ --config -c PATH Path to config file │ +│ --help -h Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ``` @@ -912,19 +958,20 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a Usage: cf logs [OPTIONS] [STACKS]... - Show stack logs. + Show stack logs. With --service, shows logs for just that service. ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ │ stacks [STACKS]... Stacks to operate on │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ -│ --all -a Run on all stacks │ -│ --host -H TEXT Filter to stacks on this host │ -│ --follow -f Follow logs │ -│ --tail -n INTEGER Number of lines (default: 20 for --all, 100 │ -│ otherwise) │ -│ --config -c PATH Path to config file │ -│ --help -h Show this message and exit. │ +│ --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 │ +│ --follow -f Follow logs │ +│ --tail -n INTEGER Number of lines (default: 20 for --all, 100 │ +│ otherwise) │ +│ --config -c PATH Path to config file │ +│ --help -h Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ``` @@ -956,15 +1003,17 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a Without arguments: shows all stacks (same as --all). With stack names: shows only those stacks. With --host: shows stacks on that host. + With --service: filters to a specific service within the stack. ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ │ stacks [STACKS]... Stacks to operate on │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ -│ --all -a Run on all stacks │ -│ --host -H TEXT Filter to stacks on this host │ -│ --config -c PATH Path to config file │ -│ --help -h Show this message and exit. │ +│ --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 │ +│ --config -c PATH Path to config file │ +│ --help -h Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ``` diff --git a/src/compose_farm/cli/common.py b/src/compose_farm/cli/common.py index 4bb9524..6d136fc 100644 --- a/src/compose_farm/cli/common.py +++ b/src/compose_farm/cli/common.py @@ -59,6 +59,10 @@ HostOption = Annotated[ str | None, typer.Option("--host", "-H", help="Filter to stacks on this host"), ] +ServiceOption = Annotated[ + str | None, + typer.Option("--service", "-s", help="Target a specific service within the stack"), +] # --- Constants (internal) --- _MISSING_PATH_PREVIEW_LIMIT = 2 diff --git a/src/compose_farm/cli/lifecycle.py b/src/compose_farm/cli/lifecycle.py index 79ecfa3..b47b38b 100644 --- a/src/compose_farm/cli/lifecycle.py +++ b/src/compose_farm/cli/lifecycle.py @@ -11,6 +11,7 @@ from compose_farm.cli.common import ( AllOption, ConfigOption, HostOption, + ServiceOption, StacksArg, format_host, get_stacks, @@ -36,11 +37,19 @@ def up( stacks: StacksArg = None, all_stacks: AllOption = False, host: HostOption = None, + service: ServiceOption = None, config: ConfigOption = None, ) -> None: """Start stacks (docker compose up -d). Auto-migrates if host changed.""" stack_list, cfg = get_stacks(stacks or [], all_stacks, config, host=host) - results = run_async(up_stacks(cfg, stack_list, raw=True)) + if service: + if len(stack_list) != 1: + 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)) + else: + results = run_async(up_stacks(cfg, stack_list, raw=True)) maybe_regenerate_traefik(cfg, results) report_results(results) @@ -98,16 +107,39 @@ def down( report_results(results) +@app.command(rich_help_panel="Lifecycle") +def stop( + stacks: StacksArg = None, + all_stacks: AllOption = False, + service: ServiceOption = None, + config: ConfigOption = None, +) -> None: + """Stop services without removing containers (docker compose stop).""" + stack_list, cfg = get_stacks(stacks or [], all_stacks, config) + if service and len(stack_list) != 1: + print_error("--service requires exactly one stack") + raise typer.Exit(1) + cmd = f"stop {service}" if service else "stop" + raw = len(stack_list) == 1 + results = run_async(run_on_stacks(cfg, stack_list, cmd, raw=raw)) + report_results(results) + + @app.command(rich_help_panel="Lifecycle") def pull( stacks: StacksArg = None, all_stacks: AllOption = False, + service: ServiceOption = None, config: ConfigOption = None, ) -> None: """Pull latest images (docker compose pull).""" stack_list, cfg = get_stacks(stacks or [], all_stacks, config) + if service and len(stack_list) != 1: + print_error("--service requires exactly one stack") + raise typer.Exit(1) + cmd = f"pull {service}" if service else "pull" raw = len(stack_list) == 1 - results = run_async(run_on_stacks(cfg, stack_list, "pull", raw=raw)) + results = run_async(run_on_stacks(cfg, stack_list, cmd, raw=raw)) report_results(results) @@ -115,12 +147,21 @@ def pull( def restart( stacks: StacksArg = None, all_stacks: AllOption = False, + service: ServiceOption = None, config: ConfigOption = None, ) -> None: - """Restart stacks (down + up).""" + """Restart stacks (down + up). With --service, restarts just that service.""" stack_list, cfg = get_stacks(stacks or [], all_stacks, config) - raw = len(stack_list) == 1 - results = run_async(run_sequential_on_stacks(cfg, stack_list, ["down", "up -d"], raw=raw)) + 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)) + else: + 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) report_results(results) @@ -129,16 +170,37 @@ def restart( def update( stacks: StacksArg = None, all_stacks: AllOption = False, + service: ServiceOption = None, config: ConfigOption = None, ) -> None: - """Update stacks (pull + build + down + up).""" + """Update stacks (pull + build + down + up). With --service, updates just that service.""" stack_list, cfg = get_stacks(stacks or [], all_stacks, config) - raw = len(stack_list) == 1 - results = run_async( - run_sequential_on_stacks( - cfg, stack_list, ["pull --ignore-buildable", "build", "down", "up -d"], raw=raw + 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: + 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) diff --git a/src/compose_farm/cli/monitoring.py b/src/compose_farm/cli/monitoring.py index 6ca46be..04714f5 100644 --- a/src/compose_farm/cli/monitoring.py +++ b/src/compose_farm/cli/monitoring.py @@ -14,6 +14,7 @@ from compose_farm.cli.common import ( AllOption, ConfigOption, HostOption, + ServiceOption, StacksArg, get_stacks, load_config_or_exit, @@ -21,7 +22,7 @@ from compose_farm.cli.common import ( run_async, run_parallel_with_progress, ) -from compose_farm.console import console +from compose_farm.console import console, print_error from compose_farm.executor import run_command, run_on_stacks from compose_farm.state import get_stacks_needing_migration, group_stacks_by_host, load_state @@ -118,6 +119,7 @@ def logs( stacks: StacksArg = None, all_stacks: AllOption = False, host: HostOption = None, + service: ServiceOption = None, follow: Annotated[bool, typer.Option("--follow", "-f", help="Follow logs")] = False, tail: Annotated[ int | None, @@ -125,8 +127,11 @@ def logs( ] = None, config: ConfigOption = None, ) -> None: - """Show stack logs.""" + """Show stack logs. With --service, shows logs for just that service.""" stack_list, cfg = get_stacks(stacks or [], all_stacks, config, host=host) + if service and len(stack_list) != 1: + print_error("--service requires exactly one stack") + raise typer.Exit(1) # Default to fewer lines when showing multiple stacks many_stacks = all_stacks or host is not None or len(stack_list) > 1 @@ -134,6 +139,8 @@ def logs( cmd = f"logs --tail {effective_tail}" if follow: cmd += " -f" + if service: + cmd += f" {service}" results = run_async(run_on_stacks(cfg, stack_list, cmd)) report_results(results) @@ -143,6 +150,7 @@ def ps( stacks: StacksArg = None, all_stacks: AllOption = False, host: HostOption = None, + service: ServiceOption = None, config: ConfigOption = None, ) -> None: """Show status of stacks. @@ -150,9 +158,14 @@ def ps( Without arguments: shows all stacks (same as --all). With stack names: shows only those stacks. With --host: shows stacks on that host. + With --service: filters to a specific service within the stack. """ stack_list, cfg = get_stacks(stacks or [], all_stacks, config, host=host, default_all=True) - results = run_async(run_on_stacks(cfg, stack_list, "ps")) + if service and len(stack_list) != 1: + print_error("--service requires exactly one stack") + raise typer.Exit(1) + cmd = f"ps {service}" if service else "ps" + results = run_async(run_on_stacks(cfg, stack_list, cmd)) report_results(results) diff --git a/src/compose_farm/web/routes/actions.py b/src/compose_farm/web/routes/actions.py index 514d0d2..49d1c32 100644 --- a/src/compose_farm/web/routes/actions.py +++ b/src/compose_farm/web/routes/actions.py @@ -33,12 +33,15 @@ def _start_task(coro_factory: Callable[[str], Coroutine[Any, Any, None]]) -> str # Allowed stack commands -ALLOWED_COMMANDS = {"up", "down", "restart", "pull", "update", "logs"} +ALLOWED_COMMANDS = {"up", "down", "restart", "pull", "update", "logs", "stop"} + +# Allowed service-level commands (no 'down' - use 'stop' for individual services) +ALLOWED_SERVICE_COMMANDS = {"logs", "pull", "restart", "up", "stop"} @router.post("/stack/{name}/{command}") async def stack_action(name: str, command: str) -> dict[str, Any]: - """Run a compose command for a stack (up, down, restart, pull, update, logs).""" + """Run a compose command for a stack (up, down, restart, pull, update, logs, stop).""" if command not in ALLOWED_COMMANDS: raise HTTPException(status_code=404, detail=f"Unknown command '{command}'") @@ -50,6 +53,23 @@ async def stack_action(name: str, command: str) -> dict[str, Any]: return {"task_id": task_id, "stack": name, "command": command} +@router.post("/stack/{name}/service/{service}/{command}") +async def service_action(name: str, service: str, command: str) -> dict[str, Any]: + """Run a compose command for a specific service within a stack.""" + if command not in ALLOWED_SERVICE_COMMANDS: + raise HTTPException(status_code=404, detail=f"Unknown command '{command}'") + + config = get_config() + if name not in config.stacks: + raise HTTPException(status_code=404, detail=f"Stack '{name}' not found") + + # Append service name to command + task_id = _start_task( + lambda tid: run_compose_streaming(config, name, f"{command} {service}", tid) + ) + return {"task_id": task_id, "stack": name, "service": service, "command": command} + + @router.post("/apply") async def apply_all() -> dict[str, Any]: """Run cf apply to reconcile all stacks.""" diff --git a/src/compose_farm/web/templates/partials/containers.html b/src/compose_farm/web/templates/partials/containers.html index a4df68a..d5deca2 100644 --- a/src/compose_farm/web/templates/partials/containers.html +++ b/src/compose_farm/web/templates/partials/containers.html @@ -1,5 +1,5 @@ {# Container list for a stack on a single host #} -{% from "partials/icons.html" import terminal %} +{% from "partials/icons.html" import terminal, rotate_ccw, scroll_text, square %} {% macro container_row(stack, container, host) %}
{% if container.State == "running" %} @@ -18,11 +18,34 @@ {{ container.State }} {% endif %} {{ container.Name }} -
- +
+
+ +
+
+ +
+
+ +
+
+ +
{% endmacro %} diff --git a/src/compose_farm/web/templates/partials/icons.html b/src/compose_farm/web/templates/partials/icons.html index 7e2fb6c..414a3ee 100644 --- a/src/compose_farm/web/templates/partials/icons.html +++ b/src/compose_farm/web/templates/partials/icons.html @@ -43,6 +43,18 @@ {% endmacro %} +{% macro rotate_ccw(size=16) %} + + + +{% endmacro %} + +{% macro scroll_text(size=16) %} + + + +{% endmacro %} + {% macro download(size=16) %}