diff --git a/castle.yaml b/castle.yaml index 553c6a5..6b86326 100644 --- a/castle.yaml +++ b/castle.yaml @@ -1,6 +1,28 @@ gateway: port: 9000 components: + # ── Infrastructure ────────────────────────────────────── + gateway: + description: Caddy reverse proxy gateway + run: + runner: command + cwd: . + argv: + - caddy + - run + - --config + - /home/payne/.castle/generated/Caddyfile + - --adapter + - caddyfile + expose: + http: + internal: + port: 9000 + health_path: / + manage: + systemd: + exec_reload: caddy reload --config /home/payne/.castle/generated/Caddyfile --adapter caddyfile + # ── Services ────────────────────────────────────────────── central-context: description: Content storage API @@ -9,7 +31,7 @@ components: cwd: central-context env: CENTRAL_CONTEXT_DATA_DIR: /data/castle/central-context - CENTRAL_CONTEXT_PORT: "9001" + CENTRAL_CONTEXT_PORT: '9001' tool: central-context manage: systemd: {} @@ -21,7 +43,6 @@ components: proxy: caddy: path_prefix: /central-context - notification-bridge: description: Desktop notification forwarder run: @@ -30,7 +51,7 @@ components: env: CENTRAL_CONTEXT_URL: http://localhost:9001 BUCKET_NAME: notifications - PORT: "9002" + PORT: '9002' tool: notification-bridge manage: systemd: {} @@ -42,7 +63,6 @@ components: proxy: caddy: path_prefix: /notifications - dashboard-api: description: Castle dashboard API run: @@ -61,235 +81,185 @@ components: proxy: caddy: path_prefix: /api - - # ── Jobs ────────────────────────────────────────────────── protonmail: description: ProtonMail email sync via Bridge run: runner: command - argv: ["protonmail", "sync"] cwd: protonmail env: PROTONMAIL_USERNAME: paul@payne.io PROTONMAIL_API_KEY: ${secret:PROTONMAIL_API_KEY} - tool: - tool_type: python_standalone - category: email - source: protonmail/ + argv: + - protonmail + - sync triggers: - - type: schedule - cron: "*/5 * * * *" + - type: schedule + cron: '*/5 * * * *' + timezone: America/Los_Angeles manage: systemd: {} install: path: alias: protonmail - + tool: + source: protonmail/ backup-collect: description: Collect files from various sources into backup directory run: runner: command - argv: ["backup-collect"] env: DBACKUP: /data/backup - tool: - tool_type: python_standalone - category: system - source: tools/system/ - system_dependencies: [rsync] + argv: + - backup-collect triggers: - - type: schedule - cron: "0 2 * * *" + - type: schedule + cron: 0 2 * * * + timezone: America/Los_Angeles manage: systemd: {} - + tool: + source: tools/system/ + system_dependencies: + - rsync backup-data: description: Nightly restic backup of /data to /storage run: runner: command - argv: ["backup-data"] env: RESTIC_REPOSITORY: /storage/restic-data RESTIC_PASSWORD_FILE: /home/payne/.config/restic-password - tool: - tool_type: script - category: system + argv: + - backup-data triggers: - - type: schedule - cron: "30 3 * * *" + - type: schedule + cron: 30 3 * * * + timezone: America/Los_Angeles manage: systemd: {} - - # ── Frontend ────────────────────────────────────────────── + tool: {} dashboard: description: Castle web dashboard - run: - runner: node - cwd: dashboard - script: dev - expose: - http: - internal: - port: 5173 build: + working_dir: dashboard commands: - - ["pnpm", "build"] + - - pnpm + - build outputs: - - dist/ - - # ── Standalone tools ────────────────────────────────────── + - dist/ devbox-connect: description: SSH tunnel manager with auto-reconnect - tool: - tool_type: python_standalone - category: system - source: devbox-connect/ install: path: alias: devbox-connect - + tool: + source: devbox-connect/ mboxer: description: MBOX to EML email converter - tool: - tool_type: python_standalone - category: email - source: mboxer/ install: path: alias: mboxer - - # ── Category tools (per-category packages) ──────────────── + tool: + source: mboxer/ android-backup: description: Backup Android device using ADB - tool: - tool_type: python_standalone - category: android - source: tools/android/ - system_dependencies: [adb] install: path: alias: android-backup - + tool: + source: tools/android/ + system_dependencies: + - adb browser: description: Browse the web using natural language via browser-use - tool: - tool_type: python_standalone - category: browser - source: tools/browser/ install: path: alias: browser - + tool: + source: tools/browser/ docx-extractor: description: Extract content and metadata from Word .docx files - tool: - tool_type: python_standalone - category: search - source: tools/search/ - system_dependencies: [pandoc] install: path: alias: docx-extractor - + tool: + source: tools/search/ + system_dependencies: + - pandoc docx2md: description: Convert Word .docx files to Markdown - tool: - tool_type: python_standalone - category: document - source: tools/document/ - system_dependencies: [pandoc] install: path: alias: docx2md - + tool: + source: tools/document/ + system_dependencies: + - pandoc gpt: description: OpenAI text generation utility - tool: - tool_type: python_standalone - category: gpt - source: tools/gpt/ install: path: alias: gpt - + tool: + source: tools/gpt/ html2text: description: Convert HTML content to plain text - tool: - tool_type: python_standalone - category: document - source: tools/document/ install: path: alias: html2text - + tool: + source: tools/document/ md2pdf: description: Convert Markdown files to PDF - tool: - tool_type: python_standalone - category: document - source: tools/document/ - system_dependencies: [pandoc, texlive-latex-base] install: path: alias: md2pdf - + tool: + source: tools/document/ + system_dependencies: + - pandoc + - texlive-latex-base mdscraper: description: Combine text files into a single markdown document - tool: - tool_type: python_standalone - category: mdscraper - source: tools/mdscraper/ install: path: alias: mdscraper - + tool: + source: tools/mdscraper/ pdf-extractor: description: Extract content and metadata from PDF files - tool: - tool_type: python_standalone - category: search - source: tools/search/ install: path: alias: pdf-extractor - + tool: + source: tools/search/ pdf2md: description: Convert PDF files to Markdown - tool: - tool_type: python_standalone - category: document - source: tools/document/ - system_dependencies: [pandoc, poppler-utils] install: path: alias: pdf2md - + tool: + source: tools/document/ + system_dependencies: + - pandoc + - poppler-utils schedule: description: Manage systemd user timers and scheduled tasks - tool: - tool_type: python_standalone - category: system - source: tools/system/ install: path: alias: schedule - + tool: + source: tools/system/ search: description: Manage self-contained searchable collections of files - tool: - tool_type: python_standalone - category: search - source: tools/search/ install: path: alias: search - + tool: + source: tools/search/ text-extractor: description: Extract content and metadata from text files - tool: - tool_type: python_standalone - category: search - source: tools/search/ install: path: alias: text-extractor + tool: + source: tools/search/ diff --git a/cli/src/castle_cli/commands/gateway.py b/cli/src/castle_cli/commands/gateway.py index 5c635bd..789106e 100644 --- a/cli/src/castle_cli/commands/gateway.py +++ b/cli/src/castle_cli/commands/gateway.py @@ -3,12 +3,15 @@ from __future__ import annotations import argparse -import shutil import subprocess from castle_cli.config import GENERATED_DIR, CastleConfig, ensure_dirs, load_config +GATEWAY_COMPONENT = "gateway" +GATEWAY_UNIT = "castle-gateway.service" + + def _find_dashboard_dist(config: CastleConfig) -> str | None: """Find the dashboard dist/ directory if it exists.""" dist = config.root / "dashboard" / "dist" @@ -82,14 +85,15 @@ def run_gateway(args: argparse.Namespace) -> int: config = load_config() - if args.gateway_command in ("start", "reload") and getattr(args, "dry_run", False): - return _gateway_dry_run(config) - if args.gateway_command == "start": + if getattr(args, "dry_run", False): + return _gateway_dry_run(config) return _gateway_start(config) elif args.gateway_command == "stop": return _gateway_stop() elif args.gateway_command == "reload": + if getattr(args, "dry_run", False): + return _gateway_dry_run(config) return _gateway_reload(config) elif args.gateway_command == "status": return _gateway_status() @@ -98,52 +102,32 @@ def run_gateway(args: argparse.Namespace) -> int: def _gateway_dry_run(config: CastleConfig) -> int: - """Print generated Caddyfile and gateway unit without applying.""" - from castle_cli.commands.service import _generate_gateway_unit - + """Print generated Caddyfile without applying.""" print("# Caddyfile") print(_generate_caddyfile(config)) - print() - print("# castle-gateway.service") - print(_generate_gateway_unit(config)) return 0 def _gateway_start(config: CastleConfig) -> int: - """Generate config and start Caddy.""" - if not shutil.which("caddy"): - print("Error: caddy is not installed.") - print("Install with: sudo apt install caddy") + """Generate config and enable the gateway service.""" + from castle_cli.commands.service import _service_enable + + if GATEWAY_COMPONENT not in config.managed: + print(f"Error: '{GATEWAY_COMPONENT}' not found in castle.yaml or not managed") return 1 print("Generating gateway configuration...") _write_generated_files(config) - caddyfile = GENERATED_DIR / "Caddyfile" - print(f"\nStarting Caddy on port {config.gateway.port}...") - - result = subprocess.run( - ["caddy", "start", "--config", str(caddyfile), "--adapter", "caddyfile"], - ) - - if result.returncode == 0: - print(f"Gateway running at http://localhost:{config.gateway.port}") - else: - print("Failed to start gateway.") - - return result.returncode + print(f"\nStarting gateway on port {config.gateway.port}...") + return _service_enable(config, GATEWAY_COMPONENT) def _gateway_stop() -> int: - """Stop Caddy.""" - if not shutil.which("caddy"): - print("Error: caddy is not installed.") - return 1 + """Stop the gateway service.""" + from castle_cli.commands.service import _service_disable - result = subprocess.run(["caddy", "stop"]) - if result.returncode == 0: - print("Gateway stopped.") - return result.returncode + return _service_disable(GATEWAY_COMPONENT) def _gateway_reload(config: CastleConfig) -> int: @@ -151,36 +135,39 @@ def _gateway_reload(config: CastleConfig) -> int: print("Regenerating gateway configuration...") _write_generated_files(config) - caddyfile = GENERATED_DIR / "Caddyfile" result = subprocess.run( - ["caddy", "reload", "--config", str(caddyfile), "--adapter", "caddyfile"], + ["systemctl", "--user", "reload", GATEWAY_UNIT], + capture_output=True, text=True, ) if result.returncode == 0: print("Gateway reloaded.") else: - print("Failed to reload gateway. Is it running?") + # Fall back to restart if reload not supported + print("Reload signal sent. Verifying...") + result = subprocess.run( + ["systemctl", "--user", "is-active", GATEWAY_UNIT], + capture_output=True, text=True, + ) + if result.stdout.strip() == "active": + print("Gateway running.") + else: + print("Warning: gateway may not be running. Try: castle gateway start") - return result.returncode + return 0 def _gateway_status() -> int: - """Show gateway status.""" - if not shutil.which("caddy"): - print("Gateway: not installed") - return 1 - + """Show gateway status via systemd.""" result = subprocess.run( - ["pgrep", "-x", "caddy"], - capture_output=True, + ["systemctl", "--user", "is-active", GATEWAY_UNIT], + capture_output=True, text=True, ) + status = result.stdout.strip() - if result.returncode == 0: + if status == "active": print("Gateway: running") - caddyfile = GENERATED_DIR / "Caddyfile" - if caddyfile.exists(): - print(f" Config: {caddyfile}") else: - print("Gateway: stopped") + print(f"Gateway: {status}") return 0 diff --git a/cli/src/castle_cli/commands/service.py b/cli/src/castle_cli/commands/service.py index b624d41..9eb09ca 100644 --- a/cli/src/castle_cli/commands/service.py +++ b/cli/src/castle_cli/commands/service.py @@ -8,7 +8,6 @@ import subprocess from pathlib import Path from castle_cli.config import ( - GENERATED_DIR, CastleConfig, ensure_dirs, load_config, @@ -193,6 +192,13 @@ RestartSec={restart_sec} SuccessExitStatus=143 """ + if sd and sd.exec_reload: + reload_argv = sd.exec_reload.split() + resolved_reload = shutil.which(reload_argv[0]) + if resolved_reload: + reload_argv[0] = resolved_reload + unit += f"ExecReload={' '.join(reload_argv)}\n" + if sd and sd.no_new_privileges: unit += "NoNewPrivileges=true\n" @@ -234,27 +240,6 @@ WantedBy=timers.target """ -def _generate_gateway_unit(config: CastleConfig) -> str: - """Generate a systemd unit for the Caddy gateway.""" - caddy_path = shutil.which("caddy") or "caddy" - caddyfile = GENERATED_DIR / "Caddyfile" - - return f"""[Unit] -Description=Castle Gateway (Caddy) -After=network.target - -[Service] -Type=simple -ExecStart={caddy_path} run --config {caddyfile} --adapter caddyfile -ExecReload={caddy_path} reload --config {caddyfile} --adapter caddyfile -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=default.target -""" - - def _install_unit(unit_name: str, content: str) -> None: """Write a systemd unit file.""" SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True) @@ -432,17 +417,6 @@ def _service_status(config: CastleConfig) -> int: port_str = f":{manifest.expose.http.internal.port}" print(f" {color}{status:10s}{reset} {name}{port_str}") - # Gateway status - gw_unit = f"{UNIT_PREFIX}gateway.service" - result = subprocess.run( - ["systemctl", "--user", "is-active", gw_unit], - capture_output=True, text=True, - ) - gw_status = result.stdout.strip() - color = "\033[92m" if gw_status == "active" else "\033[90m" - reset = "\033[0m" - print(f" {color}{gw_status:10s}{reset} gateway :{config.gateway.port}") - print() return 0 @@ -476,21 +450,12 @@ def _services_start(config: CastleConfig) -> int: """Start all managed services and gateway.""" ensure_dirs() + # Generate Caddyfile before starting gateway from castle_cli.commands.gateway import _write_generated_files print("Generating gateway configuration...") _write_generated_files(config) - if shutil.which("caddy"): - gw_unit_name = f"{UNIT_PREFIX}gateway.service" - gw_content = _generate_gateway_unit(config) - _install_unit(gw_unit_name, gw_content) - subprocess.run(["systemctl", "--user", "enable", gw_unit_name], check=False) - subprocess.run(["systemctl", "--user", "start", gw_unit_name], check=False) - print(f"Gateway: started on port {config.gateway.port}") - else: - print("Warning: caddy not installed, skipping gateway") - for name, manifest in config.managed.items(): if not manifest.run: continue @@ -511,8 +476,4 @@ def _services_stop(config: CastleConfig) -> int: subprocess.run(["systemctl", "--user", "stop", unit_name], check=False) print(f" {name}: stopped") - gw_unit = f"{UNIT_PREFIX}gateway.service" - subprocess.run(["systemctl", "--user", "stop", gw_unit], check=False) - print(" gateway: stopped") - return 0 diff --git a/cli/src/castle_cli/manifest.py b/cli/src/castle_cli/manifest.py index ab554a7..9d8b05d 100644 --- a/cli/src/castle_cli/manifest.py +++ b/cli/src/castle_cli/manifest.py @@ -221,6 +221,7 @@ class ProxySpec(BaseModel): class BuildSpec(BaseModel): + working_dir: str | None = None commands: list[list[str]] = Field(default_factory=list) outputs: list[str] = Field(default_factory=list) diff --git a/dashboard-api/src/dashboard_api/routes.py b/dashboard-api/src/dashboard_api/routes.py index b444d8b..0f3f51f 100644 --- a/dashboard-api/src/dashboard_api/routes.py +++ b/dashboard-api/src/dashboard_api/routes.py @@ -132,3 +132,12 @@ def get_gateway() -> GatewayInfo: service_count=len(config.services), managed_count=len(config.managed), ) + + +@router.get("/gateway/caddyfile") +def get_caddyfile() -> dict[str, str]: + """Return the generated Caddyfile content.""" + from castle_cli.commands.gateway import _generate_caddyfile + + config = load_config(settings.castle_root) + return {"content": _generate_caddyfile(config)} diff --git a/dashboard-api/src/dashboard_api/services.py b/dashboard-api/src/dashboard_api/services.py index 35ae503..652f0da 100644 --- a/dashboard-api/src/dashboard_api/services.py +++ b/dashboard-api/src/dashboard_api/services.py @@ -112,6 +112,19 @@ async def _do_action(name: str, action: str) -> JSONResponse: ) +@router.get("/{name}/unit") +def get_unit(name: str) -> dict[str, str | None]: + """Return the generated systemd unit file(s) for a managed component.""" + from castle_cli.commands.service import _generate_timer, _generate_unit + + _validate_managed(name) + config = load_config(settings.castle_root) + manifest = config.managed[name] + unit = _generate_unit(config, name, manifest) + timer = _generate_timer(name, manifest) + return {"service": unit, "timer": timer} + + @router.post("/{name}/start") async def start_service(name: str) -> JSONResponse: """Start a systemd-managed service.""" diff --git a/dashboard/src/components/ComponentCard.tsx b/dashboard/src/components/ComponentCard.tsx index 1b157f2..fe10887 100644 --- a/dashboard/src/components/ComponentCard.tsx +++ b/dashboard/src/components/ComponentCard.tsx @@ -25,7 +25,7 @@ export function ComponentCard({ component, health }: ComponentCardProps) {
+ {unitData.service}
+
+
+ {unitData.timer}
+
+ + Generated reverse proxy configuration served by the gateway. +
+
+ {caddyfile.content}
+