feat: Enhance gateway management and add Caddyfile generation support
This commit is contained in:
208
castle.yaml
208
castle.yaml
@@ -1,6 +1,28 @@
|
|||||||
gateway:
|
gateway:
|
||||||
port: 9000
|
port: 9000
|
||||||
components:
|
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 ──────────────────────────────────────────────
|
# ── Services ──────────────────────────────────────────────
|
||||||
central-context:
|
central-context:
|
||||||
description: Content storage API
|
description: Content storage API
|
||||||
@@ -9,7 +31,7 @@ components:
|
|||||||
cwd: central-context
|
cwd: central-context
|
||||||
env:
|
env:
|
||||||
CENTRAL_CONTEXT_DATA_DIR: /data/castle/central-context
|
CENTRAL_CONTEXT_DATA_DIR: /data/castle/central-context
|
||||||
CENTRAL_CONTEXT_PORT: "9001"
|
CENTRAL_CONTEXT_PORT: '9001'
|
||||||
tool: central-context
|
tool: central-context
|
||||||
manage:
|
manage:
|
||||||
systemd: {}
|
systemd: {}
|
||||||
@@ -21,7 +43,6 @@ components:
|
|||||||
proxy:
|
proxy:
|
||||||
caddy:
|
caddy:
|
||||||
path_prefix: /central-context
|
path_prefix: /central-context
|
||||||
|
|
||||||
notification-bridge:
|
notification-bridge:
|
||||||
description: Desktop notification forwarder
|
description: Desktop notification forwarder
|
||||||
run:
|
run:
|
||||||
@@ -30,7 +51,7 @@ components:
|
|||||||
env:
|
env:
|
||||||
CENTRAL_CONTEXT_URL: http://localhost:9001
|
CENTRAL_CONTEXT_URL: http://localhost:9001
|
||||||
BUCKET_NAME: notifications
|
BUCKET_NAME: notifications
|
||||||
PORT: "9002"
|
PORT: '9002'
|
||||||
tool: notification-bridge
|
tool: notification-bridge
|
||||||
manage:
|
manage:
|
||||||
systemd: {}
|
systemd: {}
|
||||||
@@ -42,7 +63,6 @@ components:
|
|||||||
proxy:
|
proxy:
|
||||||
caddy:
|
caddy:
|
||||||
path_prefix: /notifications
|
path_prefix: /notifications
|
||||||
|
|
||||||
dashboard-api:
|
dashboard-api:
|
||||||
description: Castle dashboard API
|
description: Castle dashboard API
|
||||||
run:
|
run:
|
||||||
@@ -61,235 +81,185 @@ components:
|
|||||||
proxy:
|
proxy:
|
||||||
caddy:
|
caddy:
|
||||||
path_prefix: /api
|
path_prefix: /api
|
||||||
|
|
||||||
# ── Jobs ──────────────────────────────────────────────────
|
|
||||||
protonmail:
|
protonmail:
|
||||||
description: ProtonMail email sync via Bridge
|
description: ProtonMail email sync via Bridge
|
||||||
run:
|
run:
|
||||||
runner: command
|
runner: command
|
||||||
argv: ["protonmail", "sync"]
|
|
||||||
cwd: protonmail
|
cwd: protonmail
|
||||||
env:
|
env:
|
||||||
PROTONMAIL_USERNAME: paul@payne.io
|
PROTONMAIL_USERNAME: paul@payne.io
|
||||||
PROTONMAIL_API_KEY: ${secret:PROTONMAIL_API_KEY}
|
PROTONMAIL_API_KEY: ${secret:PROTONMAIL_API_KEY}
|
||||||
tool:
|
argv:
|
||||||
tool_type: python_standalone
|
- protonmail
|
||||||
category: email
|
- sync
|
||||||
source: protonmail/
|
|
||||||
triggers:
|
triggers:
|
||||||
- type: schedule
|
- type: schedule
|
||||||
cron: "*/5 * * * *"
|
cron: '*/5 * * * *'
|
||||||
|
timezone: America/Los_Angeles
|
||||||
manage:
|
manage:
|
||||||
systemd: {}
|
systemd: {}
|
||||||
install:
|
install:
|
||||||
path:
|
path:
|
||||||
alias: protonmail
|
alias: protonmail
|
||||||
|
tool:
|
||||||
|
source: protonmail/
|
||||||
backup-collect:
|
backup-collect:
|
||||||
description: Collect files from various sources into backup directory
|
description: Collect files from various sources into backup directory
|
||||||
run:
|
run:
|
||||||
runner: command
|
runner: command
|
||||||
argv: ["backup-collect"]
|
|
||||||
env:
|
env:
|
||||||
DBACKUP: /data/backup
|
DBACKUP: /data/backup
|
||||||
tool:
|
argv:
|
||||||
tool_type: python_standalone
|
- backup-collect
|
||||||
category: system
|
|
||||||
source: tools/system/
|
|
||||||
system_dependencies: [rsync]
|
|
||||||
triggers:
|
triggers:
|
||||||
- type: schedule
|
- type: schedule
|
||||||
cron: "0 2 * * *"
|
cron: 0 2 * * *
|
||||||
|
timezone: America/Los_Angeles
|
||||||
manage:
|
manage:
|
||||||
systemd: {}
|
systemd: {}
|
||||||
|
tool:
|
||||||
|
source: tools/system/
|
||||||
|
system_dependencies:
|
||||||
|
- rsync
|
||||||
backup-data:
|
backup-data:
|
||||||
description: Nightly restic backup of /data to /storage
|
description: Nightly restic backup of /data to /storage
|
||||||
run:
|
run:
|
||||||
runner: command
|
runner: command
|
||||||
argv: ["backup-data"]
|
|
||||||
env:
|
env:
|
||||||
RESTIC_REPOSITORY: /storage/restic-data
|
RESTIC_REPOSITORY: /storage/restic-data
|
||||||
RESTIC_PASSWORD_FILE: /home/payne/.config/restic-password
|
RESTIC_PASSWORD_FILE: /home/payne/.config/restic-password
|
||||||
tool:
|
argv:
|
||||||
tool_type: script
|
- backup-data
|
||||||
category: system
|
|
||||||
triggers:
|
triggers:
|
||||||
- type: schedule
|
- type: schedule
|
||||||
cron: "30 3 * * *"
|
cron: 30 3 * * *
|
||||||
|
timezone: America/Los_Angeles
|
||||||
manage:
|
manage:
|
||||||
systemd: {}
|
systemd: {}
|
||||||
|
tool: {}
|
||||||
# ── Frontend ──────────────────────────────────────────────
|
|
||||||
dashboard:
|
dashboard:
|
||||||
description: Castle web dashboard
|
description: Castle web dashboard
|
||||||
run:
|
|
||||||
runner: node
|
|
||||||
cwd: dashboard
|
|
||||||
script: dev
|
|
||||||
expose:
|
|
||||||
http:
|
|
||||||
internal:
|
|
||||||
port: 5173
|
|
||||||
build:
|
build:
|
||||||
|
working_dir: dashboard
|
||||||
commands:
|
commands:
|
||||||
- ["pnpm", "build"]
|
- - pnpm
|
||||||
|
- build
|
||||||
outputs:
|
outputs:
|
||||||
- dist/
|
- dist/
|
||||||
|
|
||||||
# ── Standalone tools ──────────────────────────────────────
|
|
||||||
devbox-connect:
|
devbox-connect:
|
||||||
description: SSH tunnel manager with auto-reconnect
|
description: SSH tunnel manager with auto-reconnect
|
||||||
tool:
|
|
||||||
tool_type: python_standalone
|
|
||||||
category: system
|
|
||||||
source: devbox-connect/
|
|
||||||
install:
|
install:
|
||||||
path:
|
path:
|
||||||
alias: devbox-connect
|
alias: devbox-connect
|
||||||
|
tool:
|
||||||
|
source: devbox-connect/
|
||||||
mboxer:
|
mboxer:
|
||||||
description: MBOX to EML email converter
|
description: MBOX to EML email converter
|
||||||
tool:
|
|
||||||
tool_type: python_standalone
|
|
||||||
category: email
|
|
||||||
source: mboxer/
|
|
||||||
install:
|
install:
|
||||||
path:
|
path:
|
||||||
alias: mboxer
|
alias: mboxer
|
||||||
|
tool:
|
||||||
# ── Category tools (per-category packages) ────────────────
|
source: mboxer/
|
||||||
android-backup:
|
android-backup:
|
||||||
description: Backup Android device using ADB
|
description: Backup Android device using ADB
|
||||||
tool:
|
|
||||||
tool_type: python_standalone
|
|
||||||
category: android
|
|
||||||
source: tools/android/
|
|
||||||
system_dependencies: [adb]
|
|
||||||
install:
|
install:
|
||||||
path:
|
path:
|
||||||
alias: android-backup
|
alias: android-backup
|
||||||
|
tool:
|
||||||
|
source: tools/android/
|
||||||
|
system_dependencies:
|
||||||
|
- adb
|
||||||
browser:
|
browser:
|
||||||
description: Browse the web using natural language via browser-use
|
description: Browse the web using natural language via browser-use
|
||||||
tool:
|
|
||||||
tool_type: python_standalone
|
|
||||||
category: browser
|
|
||||||
source: tools/browser/
|
|
||||||
install:
|
install:
|
||||||
path:
|
path:
|
||||||
alias: browser
|
alias: browser
|
||||||
|
tool:
|
||||||
|
source: tools/browser/
|
||||||
docx-extractor:
|
docx-extractor:
|
||||||
description: Extract content and metadata from Word .docx files
|
description: Extract content and metadata from Word .docx files
|
||||||
tool:
|
|
||||||
tool_type: python_standalone
|
|
||||||
category: search
|
|
||||||
source: tools/search/
|
|
||||||
system_dependencies: [pandoc]
|
|
||||||
install:
|
install:
|
||||||
path:
|
path:
|
||||||
alias: docx-extractor
|
alias: docx-extractor
|
||||||
|
tool:
|
||||||
|
source: tools/search/
|
||||||
|
system_dependencies:
|
||||||
|
- pandoc
|
||||||
docx2md:
|
docx2md:
|
||||||
description: Convert Word .docx files to Markdown
|
description: Convert Word .docx files to Markdown
|
||||||
tool:
|
|
||||||
tool_type: python_standalone
|
|
||||||
category: document
|
|
||||||
source: tools/document/
|
|
||||||
system_dependencies: [pandoc]
|
|
||||||
install:
|
install:
|
||||||
path:
|
path:
|
||||||
alias: docx2md
|
alias: docx2md
|
||||||
|
tool:
|
||||||
|
source: tools/document/
|
||||||
|
system_dependencies:
|
||||||
|
- pandoc
|
||||||
gpt:
|
gpt:
|
||||||
description: OpenAI text generation utility
|
description: OpenAI text generation utility
|
||||||
tool:
|
|
||||||
tool_type: python_standalone
|
|
||||||
category: gpt
|
|
||||||
source: tools/gpt/
|
|
||||||
install:
|
install:
|
||||||
path:
|
path:
|
||||||
alias: gpt
|
alias: gpt
|
||||||
|
tool:
|
||||||
|
source: tools/gpt/
|
||||||
html2text:
|
html2text:
|
||||||
description: Convert HTML content to plain text
|
description: Convert HTML content to plain text
|
||||||
tool:
|
|
||||||
tool_type: python_standalone
|
|
||||||
category: document
|
|
||||||
source: tools/document/
|
|
||||||
install:
|
install:
|
||||||
path:
|
path:
|
||||||
alias: html2text
|
alias: html2text
|
||||||
|
tool:
|
||||||
|
source: tools/document/
|
||||||
md2pdf:
|
md2pdf:
|
||||||
description: Convert Markdown files to PDF
|
description: Convert Markdown files to PDF
|
||||||
tool:
|
|
||||||
tool_type: python_standalone
|
|
||||||
category: document
|
|
||||||
source: tools/document/
|
|
||||||
system_dependencies: [pandoc, texlive-latex-base]
|
|
||||||
install:
|
install:
|
||||||
path:
|
path:
|
||||||
alias: md2pdf
|
alias: md2pdf
|
||||||
|
tool:
|
||||||
|
source: tools/document/
|
||||||
|
system_dependencies:
|
||||||
|
- pandoc
|
||||||
|
- texlive-latex-base
|
||||||
mdscraper:
|
mdscraper:
|
||||||
description: Combine text files into a single markdown document
|
description: Combine text files into a single markdown document
|
||||||
tool:
|
|
||||||
tool_type: python_standalone
|
|
||||||
category: mdscraper
|
|
||||||
source: tools/mdscraper/
|
|
||||||
install:
|
install:
|
||||||
path:
|
path:
|
||||||
alias: mdscraper
|
alias: mdscraper
|
||||||
|
tool:
|
||||||
|
source: tools/mdscraper/
|
||||||
pdf-extractor:
|
pdf-extractor:
|
||||||
description: Extract content and metadata from PDF files
|
description: Extract content and metadata from PDF files
|
||||||
tool:
|
|
||||||
tool_type: python_standalone
|
|
||||||
category: search
|
|
||||||
source: tools/search/
|
|
||||||
install:
|
install:
|
||||||
path:
|
path:
|
||||||
alias: pdf-extractor
|
alias: pdf-extractor
|
||||||
|
tool:
|
||||||
|
source: tools/search/
|
||||||
pdf2md:
|
pdf2md:
|
||||||
description: Convert PDF files to Markdown
|
description: Convert PDF files to Markdown
|
||||||
tool:
|
|
||||||
tool_type: python_standalone
|
|
||||||
category: document
|
|
||||||
source: tools/document/
|
|
||||||
system_dependencies: [pandoc, poppler-utils]
|
|
||||||
install:
|
install:
|
||||||
path:
|
path:
|
||||||
alias: pdf2md
|
alias: pdf2md
|
||||||
|
tool:
|
||||||
|
source: tools/document/
|
||||||
|
system_dependencies:
|
||||||
|
- pandoc
|
||||||
|
- poppler-utils
|
||||||
schedule:
|
schedule:
|
||||||
description: Manage systemd user timers and scheduled tasks
|
description: Manage systemd user timers and scheduled tasks
|
||||||
tool:
|
|
||||||
tool_type: python_standalone
|
|
||||||
category: system
|
|
||||||
source: tools/system/
|
|
||||||
install:
|
install:
|
||||||
path:
|
path:
|
||||||
alias: schedule
|
alias: schedule
|
||||||
|
tool:
|
||||||
|
source: tools/system/
|
||||||
search:
|
search:
|
||||||
description: Manage self-contained searchable collections of files
|
description: Manage self-contained searchable collections of files
|
||||||
tool:
|
|
||||||
tool_type: python_standalone
|
|
||||||
category: search
|
|
||||||
source: tools/search/
|
|
||||||
install:
|
install:
|
||||||
path:
|
path:
|
||||||
alias: search
|
alias: search
|
||||||
|
tool:
|
||||||
|
source: tools/search/
|
||||||
text-extractor:
|
text-extractor:
|
||||||
description: Extract content and metadata from text files
|
description: Extract content and metadata from text files
|
||||||
tool:
|
|
||||||
tool_type: python_standalone
|
|
||||||
category: search
|
|
||||||
source: tools/search/
|
|
||||||
install:
|
install:
|
||||||
path:
|
path:
|
||||||
alias: text-extractor
|
alias: text-extractor
|
||||||
|
tool:
|
||||||
|
source: tools/search/
|
||||||
|
|||||||
@@ -3,12 +3,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import shutil
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from castle_cli.config import GENERATED_DIR, CastleConfig, ensure_dirs, load_config
|
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:
|
def _find_dashboard_dist(config: CastleConfig) -> str | None:
|
||||||
"""Find the dashboard dist/ directory if it exists."""
|
"""Find the dashboard dist/ directory if it exists."""
|
||||||
dist = config.root / "dashboard" / "dist"
|
dist = config.root / "dashboard" / "dist"
|
||||||
@@ -82,14 +85,15 @@ def run_gateway(args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
config = load_config()
|
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 args.gateway_command == "start":
|
||||||
|
if getattr(args, "dry_run", False):
|
||||||
|
return _gateway_dry_run(config)
|
||||||
return _gateway_start(config)
|
return _gateway_start(config)
|
||||||
elif args.gateway_command == "stop":
|
elif args.gateway_command == "stop":
|
||||||
return _gateway_stop()
|
return _gateway_stop()
|
||||||
elif args.gateway_command == "reload":
|
elif args.gateway_command == "reload":
|
||||||
|
if getattr(args, "dry_run", False):
|
||||||
|
return _gateway_dry_run(config)
|
||||||
return _gateway_reload(config)
|
return _gateway_reload(config)
|
||||||
elif args.gateway_command == "status":
|
elif args.gateway_command == "status":
|
||||||
return _gateway_status()
|
return _gateway_status()
|
||||||
@@ -98,52 +102,32 @@ def run_gateway(args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _gateway_dry_run(config: CastleConfig) -> int:
|
def _gateway_dry_run(config: CastleConfig) -> int:
|
||||||
"""Print generated Caddyfile and gateway unit without applying."""
|
"""Print generated Caddyfile without applying."""
|
||||||
from castle_cli.commands.service import _generate_gateway_unit
|
|
||||||
|
|
||||||
print("# Caddyfile")
|
print("# Caddyfile")
|
||||||
print(_generate_caddyfile(config))
|
print(_generate_caddyfile(config))
|
||||||
print()
|
|
||||||
print("# castle-gateway.service")
|
|
||||||
print(_generate_gateway_unit(config))
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _gateway_start(config: CastleConfig) -> int:
|
def _gateway_start(config: CastleConfig) -> int:
|
||||||
"""Generate config and start Caddy."""
|
"""Generate config and enable the gateway service."""
|
||||||
if not shutil.which("caddy"):
|
from castle_cli.commands.service import _service_enable
|
||||||
print("Error: caddy is not installed.")
|
|
||||||
print("Install with: sudo apt install caddy")
|
if GATEWAY_COMPONENT not in config.managed:
|
||||||
|
print(f"Error: '{GATEWAY_COMPONENT}' not found in castle.yaml or not managed")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
print("Generating gateway configuration...")
|
print("Generating gateway configuration...")
|
||||||
_write_generated_files(config)
|
_write_generated_files(config)
|
||||||
|
|
||||||
caddyfile = GENERATED_DIR / "Caddyfile"
|
print(f"\nStarting gateway on port {config.gateway.port}...")
|
||||||
print(f"\nStarting Caddy on port {config.gateway.port}...")
|
return _service_enable(config, GATEWAY_COMPONENT)
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _gateway_stop() -> int:
|
def _gateway_stop() -> int:
|
||||||
"""Stop Caddy."""
|
"""Stop the gateway service."""
|
||||||
if not shutil.which("caddy"):
|
from castle_cli.commands.service import _service_disable
|
||||||
print("Error: caddy is not installed.")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
result = subprocess.run(["caddy", "stop"])
|
return _service_disable(GATEWAY_COMPONENT)
|
||||||
if result.returncode == 0:
|
|
||||||
print("Gateway stopped.")
|
|
||||||
return result.returncode
|
|
||||||
|
|
||||||
|
|
||||||
def _gateway_reload(config: CastleConfig) -> int:
|
def _gateway_reload(config: CastleConfig) -> int:
|
||||||
@@ -151,36 +135,39 @@ def _gateway_reload(config: CastleConfig) -> int:
|
|||||||
print("Regenerating gateway configuration...")
|
print("Regenerating gateway configuration...")
|
||||||
_write_generated_files(config)
|
_write_generated_files(config)
|
||||||
|
|
||||||
caddyfile = GENERATED_DIR / "Caddyfile"
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["caddy", "reload", "--config", str(caddyfile), "--adapter", "caddyfile"],
|
["systemctl", "--user", "reload", GATEWAY_UNIT],
|
||||||
|
capture_output=True, text=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
print("Gateway reloaded.")
|
print("Gateway reloaded.")
|
||||||
else:
|
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:
|
def _gateway_status() -> int:
|
||||||
"""Show gateway status."""
|
"""Show gateway status via systemd."""
|
||||||
if not shutil.which("caddy"):
|
|
||||||
print("Gateway: not installed")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["pgrep", "-x", "caddy"],
|
["systemctl", "--user", "is-active", GATEWAY_UNIT],
|
||||||
capture_output=True,
|
capture_output=True, text=True,
|
||||||
)
|
)
|
||||||
|
status = result.stdout.strip()
|
||||||
|
|
||||||
if result.returncode == 0:
|
if status == "active":
|
||||||
print("Gateway: running")
|
print("Gateway: running")
|
||||||
caddyfile = GENERATED_DIR / "Caddyfile"
|
|
||||||
if caddyfile.exists():
|
|
||||||
print(f" Config: {caddyfile}")
|
|
||||||
else:
|
else:
|
||||||
print("Gateway: stopped")
|
print(f"Gateway: {status}")
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from castle_cli.config import (
|
from castle_cli.config import (
|
||||||
GENERATED_DIR,
|
|
||||||
CastleConfig,
|
CastleConfig,
|
||||||
ensure_dirs,
|
ensure_dirs,
|
||||||
load_config,
|
load_config,
|
||||||
@@ -193,6 +192,13 @@ RestartSec={restart_sec}
|
|||||||
SuccessExitStatus=143
|
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:
|
if sd and sd.no_new_privileges:
|
||||||
unit += "NoNewPrivileges=true\n"
|
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:
|
def _install_unit(unit_name: str, content: str) -> None:
|
||||||
"""Write a systemd unit file."""
|
"""Write a systemd unit file."""
|
||||||
SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
|
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}"
|
port_str = f":{manifest.expose.http.internal.port}"
|
||||||
print(f" {color}{status:10s}{reset} {name}{port_str}")
|
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()
|
print()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -476,21 +450,12 @@ def _services_start(config: CastleConfig) -> int:
|
|||||||
"""Start all managed services and gateway."""
|
"""Start all managed services and gateway."""
|
||||||
ensure_dirs()
|
ensure_dirs()
|
||||||
|
|
||||||
|
# Generate Caddyfile before starting gateway
|
||||||
from castle_cli.commands.gateway import _write_generated_files
|
from castle_cli.commands.gateway import _write_generated_files
|
||||||
|
|
||||||
print("Generating gateway configuration...")
|
print("Generating gateway configuration...")
|
||||||
_write_generated_files(config)
|
_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():
|
for name, manifest in config.managed.items():
|
||||||
if not manifest.run:
|
if not manifest.run:
|
||||||
continue
|
continue
|
||||||
@@ -511,8 +476,4 @@ def _services_stop(config: CastleConfig) -> int:
|
|||||||
subprocess.run(["systemctl", "--user", "stop", unit_name], check=False)
|
subprocess.run(["systemctl", "--user", "stop", unit_name], check=False)
|
||||||
print(f" {name}: stopped")
|
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
|
return 0
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ class ProxySpec(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class BuildSpec(BaseModel):
|
class BuildSpec(BaseModel):
|
||||||
|
working_dir: str | None = None
|
||||||
commands: list[list[str]] = Field(default_factory=list)
|
commands: list[list[str]] = Field(default_factory=list)
|
||||||
outputs: list[str] = Field(default_factory=list)
|
outputs: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|||||||
@@ -132,3 +132,12 @@ def get_gateway() -> GatewayInfo:
|
|||||||
service_count=len(config.services),
|
service_count=len(config.services),
|
||||||
managed_count=len(config.managed),
|
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)}
|
||||||
|
|||||||
@@ -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")
|
@router.post("/{name}/start")
|
||||||
async def start_service(name: str) -> JSONResponse:
|
async def start_service(name: str) -> JSONResponse:
|
||||||
"""Start a systemd-managed service."""
|
"""Start a systemd-managed service."""
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function ComponentCard({ component, health }: ComponentCardProps) {
|
|||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5">
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
<Link
|
<Link
|
||||||
to={`/${component.id}`}
|
to={`/component/${component.id}`}
|
||||||
className="text-base font-semibold hover:text-[var(--primary)] transition-colors"
|
className="text-base font-semibold hover:text-[var(--primary)] transition-colors"
|
||||||
>
|
>
|
||||||
{component.id}
|
{component.id}
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ function ComponentRow({
|
|||||||
<tr className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--card)]/50 transition-colors">
|
<tr className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--card)]/50 transition-colors">
|
||||||
<td className="px-3 py-2.5">
|
<td className="px-3 py-2.5">
|
||||||
<Link
|
<Link
|
||||||
to={`/${component.id}`}
|
to={`/component/${component.id}`}
|
||||||
className="font-medium hover:text-[var(--primary)] transition-colors"
|
className="font-medium hover:text-[var(--primary)] transition-colors"
|
||||||
title={component.systemd?.unit_path ?? undefined}
|
title={component.systemd?.unit_path ?? undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function ToolCard({ tool }: ToolCardProps) {
|
|||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5">
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
<Link
|
<Link
|
||||||
to={`/${tool.id}`}
|
to={`/component/${tool.id}`}
|
||||||
className="text-base font-semibold hover:text-[var(--primary)] transition-colors"
|
className="text-base font-semibold hover:text-[var(--primary)] transition-colors"
|
||||||
>
|
>
|
||||||
{tool.id}
|
{tool.id}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useParams, Link, useNavigate } from "react-router-dom"
|
|||||||
import { ArrowLeft, Check, Play, RefreshCw, Square } from "lucide-react"
|
import { ArrowLeft, Check, Play, RefreshCw, Square } from "lucide-react"
|
||||||
import { useQueryClient } from "@tanstack/react-query"
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
import { apiClient } from "@/services/api/client"
|
import { apiClient } from "@/services/api/client"
|
||||||
import { useComponent, useStatus, useServiceAction, useEventStream, useToolDetail } from "@/services/api/hooks"
|
import { useComponent, useStatus, useServiceAction, useEventStream, useToolDetail, useCaddyfile, useSystemdUnit } from "@/services/api/hooks"
|
||||||
import { runnerLabel } from "@/lib/labels"
|
import { runnerLabel } from "@/lib/labels"
|
||||||
import { ComponentFields } from "@/components/ComponentFields"
|
import { ComponentFields } from "@/components/ComponentFields"
|
||||||
import { HealthBadge } from "@/components/HealthBadge"
|
import { HealthBadge } from "@/components/HealthBadge"
|
||||||
@@ -22,6 +22,10 @@ export function ComponentDetailPage() {
|
|||||||
const isDown = health?.status === "down"
|
const isDown = health?.status === "down"
|
||||||
const isTool = component?.roles.includes("tool") ?? false
|
const isTool = component?.roles.includes("tool") ?? false
|
||||||
const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "")
|
const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "")
|
||||||
|
const isGateway = name === "gateway"
|
||||||
|
const { data: caddyfile } = useCaddyfile(isGateway)
|
||||||
|
const [showUnit, setShowUnit] = useState(false)
|
||||||
|
const { data: unitData } = useSystemdUnit(name ?? "", showUnit && !!component?.systemd)
|
||||||
const [message, setMessage] = useState<{ type: "ok" | "error"; text: string } | null>(null)
|
const [message, setMessage] = useState<{ type: "ok" | "error"; text: string } | null>(null)
|
||||||
|
|
||||||
const handleSave = async (compName: string, config: Record<string, unknown>) => {
|
const handleSave = async (compName: string, config: Record<string, unknown>) => {
|
||||||
@@ -189,9 +193,17 @@ export function ComponentDetailPage() {
|
|||||||
|
|
||||||
{component.systemd && (
|
{component.systemd && (
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">
|
||||||
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider">
|
||||||
Systemd
|
Systemd
|
||||||
</h2>
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUnit((v) => !v)}
|
||||||
|
className="text-xs text-[var(--primary)] hover:underline"
|
||||||
|
>
|
||||||
|
{showUnit ? "Hide unit file" : "View unit file"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm mt-3">
|
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm mt-3">
|
||||||
<span className="text-[var(--muted)]">Unit</span>
|
<span className="text-[var(--muted)]">Unit</span>
|
||||||
<span className="font-mono">{component.systemd.unit_name}</span>
|
<span className="font-mono">{component.systemd.unit_name}</span>
|
||||||
@@ -204,6 +216,40 @@ export function ComponentDetailPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{showUnit && unitData && (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-[var(--muted)] block mb-1">{component.systemd.unit_name}</span>
|
||||||
|
<pre className="text-sm whitespace-pre-wrap bg-[var(--background)] rounded p-3 border border-[var(--border)] font-mono overflow-x-auto">
|
||||||
|
{unitData.service}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
{unitData.timer && (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-[var(--muted)] block mb-1">
|
||||||
|
{component.systemd.unit_name.replace(".service", ".timer")}
|
||||||
|
</span>
|
||||||
|
<pre className="text-sm whitespace-pre-wrap bg-[var(--background)] rounded p-3 border border-[var(--border)] font-mono overflow-x-auto">
|
||||||
|
{unitData.timer}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isGateway && caddyfile?.content && (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">
|
||||||
|
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider mb-1">
|
||||||
|
Caddyfile
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-[var(--muted)] mb-3">
|
||||||
|
Generated reverse proxy configuration served by the gateway.
|
||||||
|
</p>
|
||||||
|
<pre className="text-sm whitespace-pre-wrap bg-[var(--background)] rounded p-3 border border-[var(--border)] font-mono overflow-x-auto">
|
||||||
|
{caddyfile.content}
|
||||||
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const router = createBrowserRouter([
|
|||||||
element: <Dashboard />,
|
element: <Dashboard />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/:name",
|
path: "/component/:name",
|
||||||
element: <ComponentDetailPage />,
|
element: <ComponentDetailPage />,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ export function useGateway() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCaddyfile(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["gateway", "caddyfile"],
|
||||||
|
queryFn: () => apiClient.get<{ content: string }>("/gateway/caddyfile"),
|
||||||
|
enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForApi(attempts = 20, interval = 1000): Promise<void> {
|
async function waitForApi(attempts = 20, interval = 1000): Promise<void> {
|
||||||
for (let i = 0; i < attempts; i++) {
|
for (let i = 0; i < attempts; i++) {
|
||||||
try {
|
try {
|
||||||
@@ -54,6 +62,14 @@ async function waitForApi(attempts = 20, interval = 1000): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSystemdUnit(name: string, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["services", name, "unit"],
|
||||||
|
queryFn: () => apiClient.get<{ service: string; timer: string | null }>(`/services/${name}/unit`),
|
||||||
|
enabled: enabled && !!name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useServiceAction() {
|
export function useServiceAction() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
Submodule notification-bridge updated: 3087d4066b...d62d6dfc09
62
working/decisions.md
Normal file
62
working/decisions.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Implementation Decisions
|
||||||
|
|
||||||
|
Decisions made during implementation that weren't pre-decided. Paul will review these.
|
||||||
|
|
||||||
|
## 1. CLI entry point name
|
||||||
|
|
||||||
|
Used `castle` as the command name (via `[project.scripts] castle = "castle_cli.main:main"`).
|
||||||
|
Package name is `castle-cli` to avoid conflicts, but the command is just `castle`.
|
||||||
|
|
||||||
|
## 2. Caddy `handle_path` instead of `handle`
|
||||||
|
|
||||||
|
Used `handle_path` in the Caddyfile instead of `handle`. `handle_path` automatically strips
|
||||||
|
the path prefix before proxying, so `/central-context/health` proxies to `localhost:9000/health`.
|
||||||
|
Without this, the upstream service would receive the full `/central-context/health` path and
|
||||||
|
return 404.
|
||||||
|
|
||||||
|
## 3. Service auto-port assignment
|
||||||
|
|
||||||
|
`castle create --type service` auto-assigns the next available port starting from 9000,
|
||||||
|
skipping any ports already used by other services or the gateway. This avoids port collisions
|
||||||
|
without requiring the user to track assignments manually.
|
||||||
|
|
||||||
|
## 4. Systemd unit naming convention
|
||||||
|
|
||||||
|
All castle systemd units use the prefix `castle-` (e.g., `castle-central-context.service`,
|
||||||
|
`castle-gateway.service`). This makes them easy to identify and manage as a group.
|
||||||
|
|
||||||
|
## 5. `uv` path resolution in systemd units
|
||||||
|
|
||||||
|
Systemd user units don't inherit the user's PATH. The CLI resolves `uv` to its absolute
|
||||||
|
path (via `shutil.which`) when generating unit files so they work regardless of PATH.
|
||||||
|
|
||||||
|
## 6. Dashboard health checks use direct ports
|
||||||
|
|
||||||
|
The dashboard HTML checks health by fetching directly from each service's port
|
||||||
|
(e.g., `localhost:9000/health`) rather than going through the gateway. This avoids
|
||||||
|
circular dependency if the gateway itself is having issues, and gives accurate per-service
|
||||||
|
health status.
|
||||||
|
|
||||||
|
## 7. `castle sync` runs `uv sync` before `git submodule update`
|
||||||
|
|
||||||
|
Actually runs submodule update first, then `uv sync` in each project. This way submodules
|
||||||
|
are at the right commit before dependencies are installed.
|
||||||
|
|
||||||
|
## 8. Template test structure
|
||||||
|
|
||||||
|
Scaffolded services include a health endpoint test by default. Tools include a placeholder
|
||||||
|
test. Libraries include an import test. This ensures `castle test` works immediately after
|
||||||
|
`castle create` without requiring the developer to write tests first.
|
||||||
|
|
||||||
|
## 9. `save_config` YAML formatting
|
||||||
|
|
||||||
|
When the CLI saves back to `castle.yaml` (e.g., after `castle create`), the YAML output
|
||||||
|
uses `yaml.dump` with `default_flow_style=False` and `sort_keys=False` to keep the
|
||||||
|
format readable and maintain insertion order. The format won't be identical to the original
|
||||||
|
hand-written YAML (e.g., blank lines are lost) but is functionally equivalent.
|
||||||
|
|
||||||
|
## 10. Jinja2 dependency
|
||||||
|
|
||||||
|
Added jinja2 as a dependency in the CLI's pyproject.toml for potential future template
|
||||||
|
expansion, but the current scaffold implementation uses plain f-strings. Could be removed
|
||||||
|
if it's not used soon.
|
||||||
342
working/schema.py
Normal file
342
working/schema.py
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Annotated, Dict, List, Literal, Optional, Set, Union
|
||||||
|
|
||||||
|
from pydantic import (
|
||||||
|
BaseModel,
|
||||||
|
Field,
|
||||||
|
HttpUrl,
|
||||||
|
computed_field,
|
||||||
|
model_validator,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# Shared primitives
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
EnvMap = Dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
class RestartPolicy(str, Enum):
|
||||||
|
NO = "no"
|
||||||
|
ON_FAILURE = "on-failure"
|
||||||
|
ALWAYS = "always"
|
||||||
|
|
||||||
|
|
||||||
|
class TLSMode(str, Enum):
|
||||||
|
OFF = "off"
|
||||||
|
INTERNAL = "internal"
|
||||||
|
LETSENCRYPT = "letsencrypt"
|
||||||
|
|
||||||
|
|
||||||
|
class Role(str, Enum):
|
||||||
|
TOOL = "tool"
|
||||||
|
SERVICE = "service"
|
||||||
|
WORKER = "worker"
|
||||||
|
FRONTEND = "frontend"
|
||||||
|
JOB = "job"
|
||||||
|
REMOTE = "remote"
|
||||||
|
CONTAINERIZED = "containerized"
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# Run specs (discriminated union)
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
class RunBase(BaseModel):
|
||||||
|
runner: str
|
||||||
|
cwd: Optional[str] = None
|
||||||
|
env: EnvMap = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class RunCommand(RunBase):
|
||||||
|
runner: Literal["command"]
|
||||||
|
argv: List[str] = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class RunPythonModule(RunBase):
|
||||||
|
runner: Literal["python_module"]
|
||||||
|
module: str
|
||||||
|
args: List[str] = Field(default_factory=list)
|
||||||
|
python: Optional[str] = None # e.g. "/path/to/python"
|
||||||
|
|
||||||
|
|
||||||
|
class RunPythonUvTool(RunBase):
|
||||||
|
runner: Literal["python_uv_tool"]
|
||||||
|
tool: str # the installed tool name
|
||||||
|
args: List[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class RunContainer(RunBase):
|
||||||
|
runner: Literal["container"]
|
||||||
|
image: str
|
||||||
|
command: Optional[List[str]] = None # overrides image CMD if provided
|
||||||
|
args: List[str] = Field(default_factory=list)
|
||||||
|
ports: Dict[int, int] = Field(default_factory=dict) # container_port -> host_port
|
||||||
|
volumes: List[str] = Field(default_factory=list) # "host:container[:ro]"
|
||||||
|
workdir: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RunNode(RunBase):
|
||||||
|
runner: Literal["node"]
|
||||||
|
script: str # e.g. "dev", "start", or a file path
|
||||||
|
package_manager: Literal["npm", "pnpm", "yarn"] = "pnpm"
|
||||||
|
args: List[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class RunRemote(RunBase):
|
||||||
|
runner: Literal["remote"]
|
||||||
|
base_url: HttpUrl
|
||||||
|
# Optional: metadata for how Castle should treat it
|
||||||
|
health_url: Optional[HttpUrl] = None
|
||||||
|
|
||||||
|
|
||||||
|
RunSpec = Annotated[
|
||||||
|
Union[RunCommand, RunPythonModule, RunPythonUvTool, RunContainer, RunNode, RunRemote],
|
||||||
|
Field(discriminator="runner"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# Triggers
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
class TriggerManual(BaseModel):
|
||||||
|
type: Literal["manual"] = "manual"
|
||||||
|
|
||||||
|
|
||||||
|
class TriggerSchedule(BaseModel):
|
||||||
|
type: Literal["schedule"] = "schedule"
|
||||||
|
# Keep this simple + explicit. You can later add "cron"/"interval".
|
||||||
|
cron: str # e.g. "0 * * * *"
|
||||||
|
timezone: str = "America/Los_Angeles"
|
||||||
|
|
||||||
|
|
||||||
|
class TriggerEvent(BaseModel):
|
||||||
|
type: Literal["event"] = "event"
|
||||||
|
source: str # e.g. "kafka", "redis", "webhook", "fs"
|
||||||
|
topic: Optional[str] = None
|
||||||
|
queue: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TriggerRequest(BaseModel):
|
||||||
|
type: Literal["request"] = "request"
|
||||||
|
protocol: Literal["http", "https", "grpc"] = "http"
|
||||||
|
|
||||||
|
|
||||||
|
TriggerSpec = Union[TriggerManual, TriggerSchedule, TriggerEvent, TriggerRequest]
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# Systemd management intent
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
class ReadinessHttpGet(BaseModel):
|
||||||
|
http_get: HttpUrl | str # allow templated strings like "http://127.0.0.1:${PORT}/healthz"
|
||||||
|
timeout_seconds: int = 2
|
||||||
|
interval_seconds: int = 2
|
||||||
|
success_codes: List[int] = Field(default_factory=lambda: [200])
|
||||||
|
|
||||||
|
|
||||||
|
class SystemdSpec(BaseModel):
|
||||||
|
enable: bool = True
|
||||||
|
user: bool = True
|
||||||
|
|
||||||
|
description: Optional[str] = None
|
||||||
|
after: List[str] = Field(default_factory=list)
|
||||||
|
requires: List[str] = Field(default_factory=list)
|
||||||
|
wanted_by: List[str] = Field(default_factory=lambda: ["default.target"])
|
||||||
|
|
||||||
|
restart: RestartPolicy = RestartPolicy.ON_FAILURE
|
||||||
|
restart_sec: int = 2
|
||||||
|
|
||||||
|
# Optional hardening knobs (you can expand later)
|
||||||
|
no_new_privileges: bool = True
|
||||||
|
|
||||||
|
readiness: Optional[ReadinessHttpGet] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ManageSpec(BaseModel):
|
||||||
|
systemd: Optional[SystemdSpec] = None
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# Install intent (PATH shims etc.)
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
class PathInstallSpec(BaseModel):
|
||||||
|
enable: bool = True
|
||||||
|
# If set, Castle creates a shim with this name (e.g. ~/.local/bin/<alias>)
|
||||||
|
alias: Optional[str] = None
|
||||||
|
# If true, shim forwards args to `castle run <id> ...`
|
||||||
|
shim: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class InstallSpec(BaseModel):
|
||||||
|
path: Optional[PathInstallSpec] = None
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# HTTP exposure + proxy intent
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
class HttpInternal(BaseModel):
|
||||||
|
host: str = "127.0.0.1"
|
||||||
|
port: int = Field(ge=1, le=65535)
|
||||||
|
unix_socket: Optional[str] = None # e.g. "/run/user/1000/notes.sock"
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _exactly_one_target(self):
|
||||||
|
# allow either (host+port) or unix_socket
|
||||||
|
if self.unix_socket and (self.host or self.port):
|
||||||
|
# host/port always exist via defaults; treat unix_socket as alternative
|
||||||
|
# if unix_socket is set, we ignore host/port semantically
|
||||||
|
return self
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class HttpPublic(BaseModel):
|
||||||
|
hostnames: List[str] = Field(min_length=1)
|
||||||
|
path_prefix: str = "/"
|
||||||
|
tls: TLSMode = TLSMode.INTERNAL
|
||||||
|
|
||||||
|
|
||||||
|
class HttpExposeSpec(BaseModel):
|
||||||
|
internal: HttpInternal
|
||||||
|
public: Optional[HttpPublic] = None
|
||||||
|
health_path: Optional[str] = None # e.g. "/healthz"
|
||||||
|
|
||||||
|
|
||||||
|
class ExposeSpec(BaseModel):
|
||||||
|
http: Optional[HttpExposeSpec] = None
|
||||||
|
# Future: cli, grpc, etc.
|
||||||
|
|
||||||
|
|
||||||
|
class CaddySpec(BaseModel):
|
||||||
|
enable: bool = True
|
||||||
|
# Optional: extra per-component knobs
|
||||||
|
extra_snippets: List[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ProxySpec(BaseModel):
|
||||||
|
caddy: Optional[CaddySpec] = None
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# Build spec (mostly for frontends, but generic)
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
class BuildSpec(BaseModel):
|
||||||
|
# e.g. ["pnpm", "build"] or ["uv", "run", "python", "-m", "build"]
|
||||||
|
commands: List[List[str]] = Field(default_factory=list)
|
||||||
|
outputs: List[str] = Field(default_factory=list) # paths relative to cwd/repo
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# Capabilities (optional, for dependency graph)
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
class Capability(BaseModel):
|
||||||
|
type: str # e.g. "http.endpoint", "cli.command", "ui.bundle"
|
||||||
|
name: Optional[str] = None
|
||||||
|
meta: Dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------
|
||||||
|
# The manifest (source of truth)
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
class ComponentManifest(BaseModel):
|
||||||
|
id: str = Field(pattern=r"^[a-z0-9][a-z0-9\-_.]{1,63}$")
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
run: RunSpec
|
||||||
|
|
||||||
|
triggers: List[TriggerSpec] = Field(default_factory=list)
|
||||||
|
|
||||||
|
manage: Optional[ManageSpec] = None
|
||||||
|
install: Optional[InstallSpec] = None
|
||||||
|
expose: Optional[ExposeSpec] = None
|
||||||
|
proxy: Optional[ProxySpec] = None
|
||||||
|
build: Optional[BuildSpec] = None
|
||||||
|
|
||||||
|
provides: List[Capability] = Field(default_factory=list)
|
||||||
|
consumes: List[Capability] = Field(default_factory=list)
|
||||||
|
|
||||||
|
tags: List[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
# ---- Derived ontology ----
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def roles(self) -> List[Role]:
|
||||||
|
"""
|
||||||
|
Derive roles purely from declared blocks.
|
||||||
|
No 'kind/profile' field required.
|
||||||
|
"""
|
||||||
|
roles: Set[Role] = set()
|
||||||
|
|
||||||
|
# Runner-derived roles
|
||||||
|
if self.run.runner == "remote":
|
||||||
|
roles.add(Role.REMOTE)
|
||||||
|
if self.run.runner == "container":
|
||||||
|
roles.add(Role.CONTAINERIZED)
|
||||||
|
|
||||||
|
# Interface / integration-derived roles
|
||||||
|
if self.install and self.install.path and self.install.path.enable:
|
||||||
|
roles.add(Role.TOOL)
|
||||||
|
|
||||||
|
if self.expose and self.expose.http:
|
||||||
|
roles.add(Role.SERVICE)
|
||||||
|
|
||||||
|
# Worker vs Service heuristic:
|
||||||
|
# If it's managed (systemd) but not exposed over HTTP, it's worker-ish.
|
||||||
|
if (
|
||||||
|
self.manage
|
||||||
|
and self.manage.systemd
|
||||||
|
and self.manage.systemd.enable
|
||||||
|
and not (self.expose and self.expose.http)
|
||||||
|
):
|
||||||
|
roles.add(Role.WORKER)
|
||||||
|
|
||||||
|
# Frontend heuristic: build outputs present (or node runner + build)
|
||||||
|
if self.build and (self.build.outputs or self.build.commands):
|
||||||
|
# Avoid labeling everything with build as frontend if it's clearly not;
|
||||||
|
# if it's exposing HTTP and has build, that's typically frontend or full-stack,
|
||||||
|
# but frontend label is still useful.
|
||||||
|
roles.add(Role.FRONTEND)
|
||||||
|
|
||||||
|
# Job heuristic: any schedule trigger
|
||||||
|
if any(getattr(t, "type", None) == "schedule" for t in self.triggers):
|
||||||
|
roles.add(Role.JOB)
|
||||||
|
|
||||||
|
# Fallback: if nothing else, treat as worker-ish if it’s supervised,
|
||||||
|
# otherwise tool-ish if it’s transient. But keep it conservative.
|
||||||
|
if not roles:
|
||||||
|
roles.add(Role.TOOL)
|
||||||
|
|
||||||
|
return sorted(roles, key=lambda r: r.value)
|
||||||
|
|
||||||
|
# ---- Optional consistency checks ----
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _basic_consistency(self):
|
||||||
|
# If you declare proxy.caddy, you probably mean to expose HTTP publicly.
|
||||||
|
if self.proxy and self.proxy.caddy and self.proxy.caddy.enable:
|
||||||
|
if not (self.expose and self.expose.http and self.expose.http.public):
|
||||||
|
# keep this as a soft constraint by raising a ValueError;
|
||||||
|
# remove if you prefer permissive manifests
|
||||||
|
raise ValueError(
|
||||||
|
"proxy.caddy is enabled but expose.http.public is not set. "
|
||||||
|
"Either disable caddy or declare public hostnames."
|
||||||
|
)
|
||||||
|
|
||||||
|
# If systemd is enabled, ensure runner isn't something obviously incompatible.
|
||||||
|
if self.manage and self.manage.systemd and self.manage.systemd.enable:
|
||||||
|
if self.run.runner == "remote":
|
||||||
|
raise ValueError("manage.systemd cannot be enabled for runner=remote.")
|
||||||
|
|
||||||
|
return self
|
||||||
74
working/validation-log.md
Normal file
74
working/validation-log.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Validation Log
|
||||||
|
|
||||||
|
## What was implemented
|
||||||
|
|
||||||
|
### Castle CLI (`cli/`)
|
||||||
|
- `castle --version` — shows version
|
||||||
|
- `castle list [--type] [--json]` — list all projects, filter by type, JSON output
|
||||||
|
- `castle create <name> --type service|tool|library` — scaffold project, register in castle.yaml
|
||||||
|
- `castle test [project]` — run tests across one or all projects
|
||||||
|
- `castle lint [project]` — run ruff across one or all projects
|
||||||
|
- `castle sync` — git submodule update + uv sync in all projects
|
||||||
|
- `castle gateway start|stop|reload|status` — manage Caddy reverse proxy
|
||||||
|
- `castle service enable|disable <name>` — manage individual systemd units
|
||||||
|
- `castle service status` — show all service statuses
|
||||||
|
- `castle services start|stop` — start/stop everything
|
||||||
|
|
||||||
|
### Registry (`castle.yaml`)
|
||||||
|
- All 4 existing projects registered with correct types, ports, data dirs
|
||||||
|
- `castle create` auto-registers new projects
|
||||||
|
- Auto port assignment for new services
|
||||||
|
|
||||||
|
### Gateway (Caddy)
|
||||||
|
- Caddyfile generated from castle.yaml into `~/.castle/generated/`
|
||||||
|
- Dashboard HTML with health checks served at root
|
||||||
|
- Reverse proxy with `handle_path` (strips prefix) for each service
|
||||||
|
- Gateway managed as its own systemd unit
|
||||||
|
|
||||||
|
### Systemd integration
|
||||||
|
- User units generated under `~/.config/systemd/user/castle-*.service`
|
||||||
|
- Resolves `uv` to absolute path for systemd
|
||||||
|
- Resolves `${data_dir}` in env vars
|
||||||
|
- Creates data dirs via ExecStartPre
|
||||||
|
|
||||||
|
### Project templates
|
||||||
|
- Service: FastAPI, pydantic-settings, lifespan, health endpoint, test fixtures
|
||||||
|
- Tool: argparse, stdin/stdout, exit codes
|
||||||
|
- Library: src/ layout, import test
|
||||||
|
- All templates include CLAUDE.md and pyproject.toml with ruff isort config
|
||||||
|
|
||||||
|
### Configuration files
|
||||||
|
- `castle.yaml` — project registry
|
||||||
|
- `ruff.toml` — shared ruff config (100-char lines, E/F/I/W rules)
|
||||||
|
- `pyrightconfig.json` — shared pyright config
|
||||||
|
- `CLAUDE.md` — updated with full castle system docs
|
||||||
|
- `recommendations.md` — updated with all decisions
|
||||||
|
|
||||||
|
## What was validated
|
||||||
|
|
||||||
|
| Test | Result |
|
||||||
|
|------|--------|
|
||||||
|
| `castle --version` | 0.1.0 |
|
||||||
|
| `castle list` | Shows all 4 projects grouped by type |
|
||||||
|
| `castle list --json` | Valid JSON output |
|
||||||
|
| `castle list --type service` | Filters correctly |
|
||||||
|
| `castle create --type service` | Scaffolds, registers, auto-assigns port |
|
||||||
|
| `castle create --type tool` | Scaffolds, registers |
|
||||||
|
| `castle create --type library` | Scaffolds, registers |
|
||||||
|
| `castle create` duplicate | Fails with error message |
|
||||||
|
| `castle test <project>` | Passes for scaffolded projects |
|
||||||
|
| `castle lint <project>` | Passes for scaffolded projects |
|
||||||
|
| `castle sync` | Updates submodules + uv sync in all projects |
|
||||||
|
| `castle gateway start` | Starts Caddy, dashboard accessible |
|
||||||
|
| `castle gateway reload` | Regenerates and reloads |
|
||||||
|
| `castle gateway status` | Shows running/stopped |
|
||||||
|
| `castle service enable` | Creates unit, enables, starts |
|
||||||
|
| `castle service disable` | Stops, removes unit |
|
||||||
|
| `castle service status` | Shows all service statuses with colors |
|
||||||
|
| `castle services start` | Starts all services + gateway |
|
||||||
|
| `castle services stop` | Stops everything |
|
||||||
|
| Gateway reverse proxy | Routes correctly (path prefix stripped) |
|
||||||
|
| Gateway dashboard | Serves HTML with health check JS |
|
||||||
|
| Direct service access | All services respond on their ports |
|
||||||
|
| CLI unit tests | 32/32 passing |
|
||||||
|
| CLI lint | All checks passed |
|
||||||
Reference in New Issue
Block a user