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
This commit is contained in:
Bas Nijholt
2025-12-20 20:56:48 -08:00
committed by GitHub
parent 7caf006e07
commit 59b797a89d
8 changed files with 237 additions and 53 deletions

View File

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

111
README.md
View File

@@ -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 <stack>` | Start stack (auto-migrates if host changed) |
| `cf down <stack>` | Stop stack |
| `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 |
@@ -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
</details>
<details>
<summary>See the output of <code>cf stop --help</code></summary>
<!-- CODE:BASH:START -->
<!-- echo '```yaml' -->
<!-- export NO_COLOR=1 -->
<!-- export TERM=dumb -->
<!-- export TERMINAL_WIDTH=90 -->
<!-- cf stop --help -->
<!-- echo '```' -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```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. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
<!-- OUTPUT:END -->
</details>
<details>
<summary>See the output of <code>cf pull --help</code></summary>
@@ -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. │
╰──────────────────────────────────────────────────────────────────────────────╯
```

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) %}
<div class="flex items-center gap-2 mb-2">
{% if container.State == "running" %}
@@ -18,11 +18,34 @@
<span class="badge badge-warning">{{ container.State }}</span>
{% endif %}
<code class="text-sm flex-1">{{ container.Name }}</code>
<div class="tooltip tooltip-left" data-tip="Open shell in container">
<button class="btn btn-sm btn-outline"
onclick="initExecTerminal('{{ stack }}', '{{ container.Name }}', '{{ host }}')">
{{ terminal() }} Shell
</button>
<div class="join">
<div class="tooltip tooltip-top" data-tip="View logs">
<button class="btn btn-sm btn-outline join-item"
hx-post="/api/stack/{{ stack }}/service/{{ container.Service }}/logs"
hx-swap="none">
{{ scroll_text() }}
</button>
</div>
<div class="tooltip tooltip-top" data-tip="Restart service">
<button class="btn btn-sm btn-outline join-item"
hx-post="/api/stack/{{ stack }}/service/{{ container.Service }}/restart"
hx-swap="none">
{{ rotate_ccw() }}
</button>
</div>
<div class="tooltip tooltip-top" data-tip="Stop service">
<button class="btn btn-sm btn-outline join-item"
hx-post="/api/stack/{{ stack }}/service/{{ container.Service }}/stop"
hx-swap="none">
{{ square() }}
</button>
</div>
<div class="tooltip tooltip-top" data-tip="Open shell">
<button class="btn btn-sm btn-outline join-item"
onclick="initExecTerminal('{{ stack }}', '{{ container.Name }}', '{{ host }}')">
{{ terminal() }}
</button>
</div>
</div>
</div>
{% endmacro %}

View File

@@ -43,6 +43,18 @@
</svg>
{% endmacro %}
{% macro rotate_ccw(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>
</svg>
{% endmacro %}
{% macro scroll_text(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>
</svg>
{% endmacro %}
{% macro download(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>