mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 14:13:26 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6436becff9 | ||
|
|
3460d8a3ea | ||
|
|
8dabc27272 | ||
|
|
5e08f1d712 | ||
|
|
8302f1d97a |
@@ -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 |
|
||||
|
||||
25
README.md
25
README.md
@@ -369,8 +369,8 @@ The CLI is available as both `compose-farm` and the shorter `cf` alias.
|
||||
| `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 restart <stack>` | Restart running containers |
|
||||
| `cf update <stack>` | Pull, build, recreate only if changed |
|
||||
| `cf pull <stack>` | Pull latest images |
|
||||
| `cf logs -f <stack>` | Follow logs |
|
||||
| `cf ps` | Show status of all stacks |
|
||||
@@ -400,10 +400,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 +473,8 @@ 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. Only recreates containers if images changed. │
|
||||
│ apply Make reality match config (start, migrate, stop │
|
||||
│ strays/orphans as needed). │
|
||||
│ compose Run any docker compose command on a stack. │
|
||||
@@ -525,6 +523,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 +659,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 +694,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. Only recreates containers if images changed.
|
||||
|
||||
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
|
||||
│ stacks [STACKS]... Stacks to operate on │
|
||||
@@ -1283,12 +1282,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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ The Compose Farm CLI is available as both `compose-farm` and the shorter alias `
|
||||
| | `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` | Update stacks (only recreates if images changed) |
|
||||
| | `pull` | Pull latest images |
|
||||
| | `compose` | Run any docker compose command |
|
||||
| **Monitoring** | `ps` | Show stack status |
|
||||
@@ -197,7 +197,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 +225,7 @@ cf restart immich --service database
|
||||
|
||||
### cf update
|
||||
|
||||
Update stacks (pull + build + down + up). With `--service`, updates just that service.
|
||||
Update stacks. Only recreates containers if images changed. With `--service`, updates just that service.
|
||||
|
||||
<video autoplay loop muted playsinline>
|
||||
<source src="/assets/update.webm" type="video/webm">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Hatch build hook to vendor CDN assets for offline use.
|
||||
|
||||
During wheel builds, this hook:
|
||||
1. Parses base.html to find elements with data-vendor attributes
|
||||
1. Reads vendor-assets.json to find assets marked for vendoring
|
||||
2. Downloads each CDN asset to a temporary vendor directory
|
||||
3. Rewrites base.html to use local /static/vendor/ paths
|
||||
4. Fetches and bundles license information
|
||||
@@ -13,6 +13,7 @@ distributed wheel has vendored assets.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -23,22 +24,6 @@ from urllib.request import Request, urlopen
|
||||
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||
|
||||
# Matches elements with data-vendor attribute: extracts URL and target filename
|
||||
# Example: <script src="https://..." data-vendor="htmx.js">
|
||||
# Captures: (1) src/href, (2) URL, (3) attributes between, (4) vendor filename
|
||||
VENDOR_PATTERN = re.compile(r'(src|href)="(https://[^"]+)"([^>]*?)data-vendor="([^"]+)"')
|
||||
|
||||
# License URLs for each package (GitHub raw URLs)
|
||||
LICENSE_URLS: dict[str, tuple[str, str]] = {
|
||||
"htmx": ("MIT", "https://raw.githubusercontent.com/bigskysoftware/htmx/master/LICENSE"),
|
||||
"xterm": ("MIT", "https://raw.githubusercontent.com/xtermjs/xterm.js/master/LICENSE"),
|
||||
"daisyui": ("MIT", "https://raw.githubusercontent.com/saadeghi/daisyui/master/LICENSE"),
|
||||
"tailwindcss": (
|
||||
"MIT",
|
||||
"https://raw.githubusercontent.com/tailwindlabs/tailwindcss/master/LICENSE",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _download(url: str) -> bytes:
|
||||
"""Download a URL, trying urllib first then curl as fallback."""
|
||||
@@ -61,7 +46,14 @@ def _download(url: str) -> bytes:
|
||||
return bytes(result.stdout)
|
||||
|
||||
|
||||
def _generate_licenses_file(temp_dir: Path) -> None:
|
||||
def _load_vendor_assets(root: Path) -> dict[str, Any]:
|
||||
"""Load vendor-assets.json from the web module."""
|
||||
json_path = root / "src" / "compose_farm" / "web" / "vendor-assets.json"
|
||||
with json_path.open() as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _generate_licenses_file(temp_dir: Path, licenses: dict[str, dict[str, str]]) -> None:
|
||||
"""Download and combine license files into LICENSES.txt."""
|
||||
lines = [
|
||||
"# Vendored Dependencies - License Information",
|
||||
@@ -73,7 +65,9 @@ def _generate_licenses_file(temp_dir: Path) -> None:
|
||||
"",
|
||||
]
|
||||
|
||||
for pkg_name, (license_type, license_url) in LICENSE_URLS.items():
|
||||
for pkg_name, license_info in licenses.items():
|
||||
license_type = license_info["type"]
|
||||
license_url = license_info["url"]
|
||||
lines.append(f"## {pkg_name} ({license_type})")
|
||||
lines.append(f"Source: {license_url}")
|
||||
lines.append("")
|
||||
@@ -107,44 +101,57 @@ class VendorAssetsHook(BuildHookInterface): # type: ignore[misc]
|
||||
if not base_html_path.exists():
|
||||
return
|
||||
|
||||
# Load vendor assets configuration
|
||||
vendor_config = _load_vendor_assets(Path(self.root))
|
||||
assets_to_vendor = vendor_config["assets"]
|
||||
|
||||
if not assets_to_vendor:
|
||||
return
|
||||
|
||||
# Create temp directory for vendored assets
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="compose_farm_vendor_"))
|
||||
vendor_dir = temp_dir / "vendor"
|
||||
vendor_dir.mkdir()
|
||||
|
||||
# Read and parse base.html
|
||||
# Read base.html
|
||||
html_content = base_html_path.read_text()
|
||||
|
||||
# Build URL to filename mapping and download assets
|
||||
url_to_filename: dict[str, str] = {}
|
||||
|
||||
# Find all elements with data-vendor attribute and download them
|
||||
for match in VENDOR_PATTERN.finditer(html_content):
|
||||
url = match.group(2)
|
||||
filename = match.group(4)
|
||||
|
||||
if url in url_to_filename:
|
||||
continue
|
||||
|
||||
for asset in assets_to_vendor:
|
||||
url = asset["url"]
|
||||
filename = asset["filename"]
|
||||
url_to_filename[url] = filename
|
||||
filepath = vendor_dir / filename
|
||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = _download(url)
|
||||
(vendor_dir / filename).write_bytes(content)
|
||||
filepath.write_bytes(content)
|
||||
|
||||
if not url_to_filename:
|
||||
return
|
||||
# Generate LICENSES.txt from the JSON config
|
||||
_generate_licenses_file(vendor_dir, vendor_config["licenses"])
|
||||
|
||||
# Generate LICENSES.txt
|
||||
_generate_licenses_file(vendor_dir)
|
||||
# Rewrite HTML: replace CDN URLs with local paths and remove data-vendor attributes
|
||||
# Pattern matches: src="URL" ... data-vendor="filename" or href="URL" ... data-vendor="filename"
|
||||
vendor_pattern = re.compile(r'(src|href)="(https://[^"]+)"([^>]*?)data-vendor="([^"]+)"')
|
||||
|
||||
# Rewrite HTML to use local paths (remove data-vendor, update URL)
|
||||
def replace_vendor_tag(match: re.Match[str]) -> str:
|
||||
attr = match.group(1) # src or href
|
||||
url = match.group(2)
|
||||
between = match.group(3) # attributes between URL and data-vendor
|
||||
filename = match.group(4)
|
||||
if url in url_to_filename:
|
||||
filename = url_to_filename[url]
|
||||
return f'{attr}="/static/vendor/{filename}"{between}'
|
||||
return match.group(0)
|
||||
|
||||
modified_html = VENDOR_PATTERN.sub(replace_vendor_tag, html_content)
|
||||
modified_html = vendor_pattern.sub(replace_vendor_tag, html_content)
|
||||
|
||||
# Inject vendored mode flag for JavaScript to detect
|
||||
# Insert right after <head> tag so it's available early
|
||||
modified_html = modified_html.replace(
|
||||
"<head>",
|
||||
"<head>\n <script>window.CF_VENDORED=true;</script>",
|
||||
1, # Only replace first occurrence
|
||||
)
|
||||
|
||||
# Write modified base.html to temp
|
||||
templates_dir = temp_dir / "templates"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,9 @@ 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. Only recreates containers if images changed."""
|
||||
# update is just 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 +312,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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,78 +1,39 @@
|
||||
"""CDN asset definitions and caching for tests and demo recordings.
|
||||
|
||||
This module provides a single source of truth for CDN asset URLs used in
|
||||
browser tests and demo recordings. Assets are intercepted and served from
|
||||
a local cache to eliminate network variability.
|
||||
This module provides CDN asset URLs used in browser tests and demo recordings.
|
||||
Assets are intercepted and served from a local cache to eliminate network
|
||||
variability.
|
||||
|
||||
Note: The canonical list of CDN assets for production is in base.html
|
||||
(with data-vendor attributes). This module includes those plus dynamically
|
||||
loaded assets (like Monaco editor modules loaded by app.js).
|
||||
The canonical list of CDN assets is in vendor-assets.json. This module loads
|
||||
that file and provides the CDN_ASSETS dict for test caching.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_cdn_assets() -> dict[str, tuple[str, str]]:
|
||||
"""Load CDN assets from vendor-assets.json.
|
||||
|
||||
Returns:
|
||||
Dict mapping URL to (filename, content_type) tuple.
|
||||
|
||||
"""
|
||||
json_path = Path(__file__).parent / "vendor-assets.json"
|
||||
with json_path.open() as f:
|
||||
config = json.load(f)
|
||||
|
||||
return {asset["url"]: (asset["filename"], asset["content_type"]) for asset in config["assets"]}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
# CDN assets to cache locally for tests/demos
|
||||
# Format: URL -> (local_filename, content_type)
|
||||
#
|
||||
# If tests fail with "Uncached CDN request", add the URL here.
|
||||
CDN_ASSETS: dict[str, tuple[str, str]] = {
|
||||
# From base.html (data-vendor attributes)
|
||||
"https://cdn.jsdelivr.net/npm/daisyui@5/themes.css": ("daisyui-themes.css", "text/css"),
|
||||
"https://cdn.jsdelivr.net/npm/daisyui@5": ("daisyui.css", "text/css"),
|
||||
"https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4": (
|
||||
"tailwind.js",
|
||||
"application/javascript",
|
||||
),
|
||||
"https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css": ("xterm.css", "text/css"),
|
||||
"https://unpkg.com/htmx.org@2.0.4": ("htmx.js", "application/javascript"),
|
||||
"https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js": (
|
||||
"xterm.js",
|
||||
"application/javascript",
|
||||
),
|
||||
"https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js": (
|
||||
"xterm-fit.js",
|
||||
"application/javascript",
|
||||
),
|
||||
"https://unpkg.com/idiomorph/dist/idiomorph.min.js": (
|
||||
"idiomorph.js",
|
||||
"application/javascript",
|
||||
),
|
||||
"https://unpkg.com/idiomorph/dist/idiomorph-ext.min.js": (
|
||||
"idiomorph-ext.js",
|
||||
"application/javascript",
|
||||
),
|
||||
# Monaco editor - dynamically loaded by app.js
|
||||
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js": (
|
||||
"monaco-loader.js",
|
||||
"application/javascript",
|
||||
),
|
||||
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.js": (
|
||||
"monaco-editor-main.js",
|
||||
"application/javascript",
|
||||
),
|
||||
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.css": (
|
||||
"monaco-editor-main.css",
|
||||
"text/css",
|
||||
),
|
||||
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/worker/workerMain.js": (
|
||||
"monaco-workerMain.js",
|
||||
"application/javascript",
|
||||
),
|
||||
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/basic-languages/yaml/yaml.js": (
|
||||
"monaco-yaml.js",
|
||||
"application/javascript",
|
||||
),
|
||||
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/browser/ui/codicons/codicon/codicon.ttf": (
|
||||
"monaco-codicon.ttf",
|
||||
"font/ttf",
|
||||
),
|
||||
}
|
||||
# If tests fail with "Uncached CDN request", add the URL to vendor-assets.json.
|
||||
CDN_ASSETS: dict[str, tuple[str, str]] = _load_cdn_assets()
|
||||
|
||||
|
||||
def download_url(url: str) -> bytes | None:
|
||||
@@ -107,6 +68,7 @@ def ensure_vendor_cache(cache_dir: Path) -> Path:
|
||||
filepath = cache_dir / filename
|
||||
if filepath.exists():
|
||||
continue
|
||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = download_url(url)
|
||||
if not content:
|
||||
msg = f"Failed to download {url} - check network/curl"
|
||||
|
||||
@@ -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)}"}
|
||||
|
||||
@@ -332,10 +332,14 @@ function loadMonaco(callback) {
|
||||
monacoLoading = true;
|
||||
|
||||
// Load the Monaco loader script
|
||||
// Use local paths when running from vendored wheel, CDN otherwise
|
||||
const monacoBase = window.CF_VENDORED
|
||||
? '/static/vendor/monaco'
|
||||
: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs';
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js';
|
||||
script.src = monacoBase + '/loader.js';
|
||||
script.onload = function() {
|
||||
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs' }});
|
||||
require.config({ paths: { vs: monacoBase }});
|
||||
require(['vs/editor/editor.main'], function() {
|
||||
monacoLoaded = true;
|
||||
monacoLoading = false;
|
||||
@@ -604,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),
|
||||
@@ -624,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),
|
||||
);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -97,8 +97,8 @@
|
||||
|
||||
<!-- Scripts - HTMX first -->
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4" data-vendor="htmx.js"></script>
|
||||
<script src="https://unpkg.com/idiomorph/dist/idiomorph.min.js"></script>
|
||||
<script src="https://unpkg.com/idiomorph/dist/idiomorph-ext.min.js"></script>
|
||||
<script src="https://unpkg.com/idiomorph/dist/idiomorph.min.js" data-vendor="idiomorph.js"></script>
|
||||
<script src="https://unpkg.com/idiomorph/dist/idiomorph-ext.min.js" data-vendor="idiomorph-ext.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js" data-vendor="xterm.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js" data-vendor="xterm-fit.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
122
src/compose_farm/web/vendor-assets.json
Normal file
122
src/compose_farm/web/vendor-assets.json
Normal file
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$comment": "CDN assets vendored into production builds and cached for tests",
|
||||
"assets": [
|
||||
{
|
||||
"url": "https://cdn.jsdelivr.net/npm/daisyui@5",
|
||||
"filename": "daisyui.css",
|
||||
"content_type": "text/css",
|
||||
"package": "daisyui"
|
||||
},
|
||||
{
|
||||
"url": "https://cdn.jsdelivr.net/npm/daisyui@5/themes.css",
|
||||
"filename": "daisyui-themes.css",
|
||||
"content_type": "text/css",
|
||||
"package": "daisyui"
|
||||
},
|
||||
{
|
||||
"url": "https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4",
|
||||
"filename": "tailwind.js",
|
||||
"content_type": "application/javascript",
|
||||
"package": "tailwindcss"
|
||||
},
|
||||
{
|
||||
"url": "https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css",
|
||||
"filename": "xterm.css",
|
||||
"content_type": "text/css",
|
||||
"package": "xterm"
|
||||
},
|
||||
{
|
||||
"url": "https://unpkg.com/htmx.org@2.0.4",
|
||||
"filename": "htmx.js",
|
||||
"content_type": "application/javascript",
|
||||
"package": "htmx"
|
||||
},
|
||||
{
|
||||
"url": "https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js",
|
||||
"filename": "xterm.js",
|
||||
"content_type": "application/javascript",
|
||||
"package": "xterm"
|
||||
},
|
||||
{
|
||||
"url": "https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js",
|
||||
"filename": "xterm-fit.js",
|
||||
"content_type": "application/javascript",
|
||||
"package": "xterm"
|
||||
},
|
||||
{
|
||||
"url": "https://unpkg.com/idiomorph/dist/idiomorph.min.js",
|
||||
"filename": "idiomorph.js",
|
||||
"content_type": "application/javascript",
|
||||
"package": "idiomorph"
|
||||
},
|
||||
{
|
||||
"url": "https://unpkg.com/idiomorph/dist/idiomorph-ext.min.js",
|
||||
"filename": "idiomorph-ext.js",
|
||||
"content_type": "application/javascript",
|
||||
"package": "idiomorph"
|
||||
},
|
||||
{
|
||||
"url": "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js",
|
||||
"filename": "monaco/loader.js",
|
||||
"content_type": "application/javascript",
|
||||
"package": "monaco-editor"
|
||||
},
|
||||
{
|
||||
"url": "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.js",
|
||||
"filename": "monaco/editor/editor.main.js",
|
||||
"content_type": "application/javascript",
|
||||
"package": "monaco-editor"
|
||||
},
|
||||
{
|
||||
"url": "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.css",
|
||||
"filename": "monaco/editor/editor.main.css",
|
||||
"content_type": "text/css",
|
||||
"package": "monaco-editor"
|
||||
},
|
||||
{
|
||||
"url": "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/worker/workerMain.js",
|
||||
"filename": "monaco/base/worker/workerMain.js",
|
||||
"content_type": "application/javascript",
|
||||
"package": "monaco-editor"
|
||||
},
|
||||
{
|
||||
"url": "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/basic-languages/yaml/yaml.js",
|
||||
"filename": "monaco/basic-languages/yaml/yaml.js",
|
||||
"content_type": "application/javascript",
|
||||
"package": "monaco-editor"
|
||||
},
|
||||
{
|
||||
"url": "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/browser/ui/codicons/codicon/codicon.ttf",
|
||||
"filename": "monaco/base/browser/ui/codicons/codicon/codicon.ttf",
|
||||
"content_type": "font/ttf",
|
||||
"package": "monaco-editor"
|
||||
}
|
||||
],
|
||||
"licenses": {
|
||||
"htmx": {
|
||||
"type": "MIT",
|
||||
"url": "https://raw.githubusercontent.com/bigskysoftware/htmx/master/LICENSE"
|
||||
},
|
||||
"idiomorph": {
|
||||
"type": "BSD-2-Clause",
|
||||
"url": "https://raw.githubusercontent.com/bigskysoftware/idiomorph/main/LICENSE"
|
||||
},
|
||||
"xterm": {
|
||||
"type": "MIT",
|
||||
"url": "https://raw.githubusercontent.com/xtermjs/xterm.js/master/LICENSE"
|
||||
},
|
||||
"daisyui": {
|
||||
"type": "MIT",
|
||||
"url": "https://raw.githubusercontent.com/saadeghi/daisyui/master/LICENSE"
|
||||
},
|
||||
"tailwindcss": {
|
||||
"type": "MIT",
|
||||
"url": "https://raw.githubusercontent.com/tailwindlabs/tailwindcss/master/LICENSE"
|
||||
},
|
||||
"monaco-editor": {
|
||||
"type": "MIT",
|
||||
"url": "https://raw.githubusercontent.com/microsoft/monaco-editor/main/LICENSE.txt"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -4,7 +4,7 @@ Run with: uv run pytest tests/web/test_htmx_browser.py -v --no-cov
|
||||
|
||||
CDN assets are cached locally (in .pytest_cache/vendor/) to eliminate network
|
||||
variability. If a test fails with "Uncached CDN request", add the URL to
|
||||
compose_farm.web.cdn.CDN_ASSETS.
|
||||
src/compose_farm/web/vendor-assets.json.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -90,7 +90,7 @@ def page(page: Page, vendor_cache: Path) -> Page:
|
||||
return
|
||||
# Uncached CDN request - abort with helpful error
|
||||
route.abort("failed")
|
||||
msg = f"Uncached CDN request: {url}\n\nAdd this URL to CDN_ASSETS in tests/web/test_htmx_browser.py"
|
||||
msg = f"Uncached CDN request: {url}\n\nAdd this URL to src/compose_farm/web/vendor-assets.json"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
page.route(re.compile(r"https://(cdn\.jsdelivr\.net|unpkg\.com)/.*"), handle_cdn)
|
||||
|
||||
Reference in New Issue
Block a user