refactor: Restructure ~/.castle/ as the instance directory
Move all instance state into ~/.castle/ with clear separation: - code/ — user project source (was components/) - artifacts/ — generated specs, built content (was generated/, static/) - data/ — service runtime data (was /data/castle/) - secrets/ — credentials castle.yaml moves to ~/.castle/castle.yaml as the canonical location. Source paths use repo: prefix for git repo programs (castle-api, app) and relative paths for user projects (code/central-context). Add idempotent install.sh for bootstrapping infrastructure (Docker, Caddy, MQTT, Postgres, Neo4j) with interactive service detection. Remove components/ from repo (now in ~/.castle/code/), deinit submodules, remove .gitmodules.
This commit is contained in:
@@ -12,28 +12,41 @@ import yaml
|
||||
from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec
|
||||
|
||||
|
||||
CASTLE_HOME = Path.home() / ".castle"
|
||||
CODE_DIR = CASTLE_HOME / "code"
|
||||
ARTIFACTS_DIR = CASTLE_HOME / "artifacts"
|
||||
SPECS_DIR = ARTIFACTS_DIR / "specs"
|
||||
CONTENT_DIR = ARTIFACTS_DIR / "content"
|
||||
DATA_DIR = CASTLE_HOME / "data"
|
||||
SECRETS_DIR = CASTLE_HOME / "secrets"
|
||||
|
||||
# Backwards-compat aliases (used by existing imports)
|
||||
GENERATED_DIR = SPECS_DIR
|
||||
STATIC_DIR = CONTENT_DIR
|
||||
|
||||
|
||||
def find_castle_root() -> Path:
|
||||
"""Find the castle repository root by walking up from cwd looking for castle.yaml."""
|
||||
"""Find the castle config root (directory containing castle.yaml).
|
||||
|
||||
Search order:
|
||||
1. ~/.castle/castle.yaml (the canonical instance location)
|
||||
2. Walk up from cwd (for development/testing)
|
||||
"""
|
||||
# Canonical location first
|
||||
if (CASTLE_HOME / "castle.yaml").exists():
|
||||
return CASTLE_HOME
|
||||
# Fallback: walk up from cwd
|
||||
current = Path.cwd()
|
||||
while current != current.parent:
|
||||
if (current / "castle.yaml").exists():
|
||||
return current
|
||||
current = current.parent
|
||||
# Fallback: check if castle.yaml is in a well-known location
|
||||
default = Path("/data/repos/castle")
|
||||
if (default / "castle.yaml").exists():
|
||||
return default
|
||||
raise FileNotFoundError(
|
||||
"Could not find castle.yaml. Run castle from within the castle repository."
|
||||
"Could not find castle.yaml.\n"
|
||||
f"Expected at: {CASTLE_HOME / 'castle.yaml'}"
|
||||
)
|
||||
|
||||
|
||||
CASTLE_HOME = Path.home() / ".castle"
|
||||
GENERATED_DIR = CASTLE_HOME / "generated"
|
||||
SECRETS_DIR = CASTLE_HOME / "secrets"
|
||||
STATIC_DIR = CASTLE_HOME / "static"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GatewayConfig:
|
||||
"""Gateway configuration."""
|
||||
@@ -47,6 +60,7 @@ class CastleConfig:
|
||||
|
||||
root: Path
|
||||
gateway: GatewayConfig
|
||||
repo: Path | None
|
||||
programs: dict[str, ProgramSpec]
|
||||
services: dict[str, ServiceSpec]
|
||||
jobs: dict[str, JobSpec]
|
||||
@@ -130,14 +144,23 @@ def load_config(root: Path | None = None) -> CastleConfig:
|
||||
gateway_data = data.get("gateway", {})
|
||||
gateway = GatewayConfig(port=gateway_data.get("port", 9000))
|
||||
|
||||
# repo: field points to the git repo for repo-relative sources
|
||||
repo_path: Path | None = None
|
||||
if data.get("repo"):
|
||||
repo_path = Path(data["repo"]).expanduser()
|
||||
|
||||
programs: dict[str, ProgramSpec] = {}
|
||||
# Support both "programs:" and legacy "components:" key
|
||||
programs_data = data.get("programs") or data.get("components") or {}
|
||||
for name, comp_data in programs_data.items():
|
||||
prog = _parse_program(name, comp_data)
|
||||
# Resolve relative source paths to absolute
|
||||
if prog.source and not Path(prog.source).is_absolute():
|
||||
prog.source = str(root / prog.source)
|
||||
# Resolve source paths to absolute
|
||||
if prog.source:
|
||||
if prog.source.startswith("repo:") and repo_path:
|
||||
# repo:castle-api → /data/repos/castle/castle-api
|
||||
prog.source = str(repo_path / prog.source[5:])
|
||||
elif not Path(prog.source).is_absolute():
|
||||
prog.source = str(root / prog.source)
|
||||
programs[name] = prog
|
||||
|
||||
services: dict[str, ServiceSpec] = {}
|
||||
@@ -150,6 +173,7 @@ def load_config(root: Path | None = None) -> CastleConfig:
|
||||
|
||||
return CastleConfig(
|
||||
root=root,
|
||||
repo=repo_path,
|
||||
gateway=gateway,
|
||||
programs=programs,
|
||||
services=services,
|
||||
@@ -231,14 +255,27 @@ def save_config(config: CastleConfig) -> None:
|
||||
"""Save castle configuration to castle.yaml."""
|
||||
data: dict = {"gateway": {"port": config.gateway.port}}
|
||||
|
||||
if config.repo:
|
||||
data["repo"] = str(config.repo)
|
||||
|
||||
if config.programs:
|
||||
data["programs"] = {}
|
||||
for name, spec in config.programs.items():
|
||||
d = _spec_to_yaml_dict(spec)
|
||||
# Store relative source paths in YAML
|
||||
if d.get("source") and Path(d["source"]).is_absolute():
|
||||
src = Path(d["source"])
|
||||
# If source is under repo, store as repo:relative
|
||||
if config.repo:
|
||||
try:
|
||||
d["source"] = "repo:" + str(src.relative_to(config.repo))
|
||||
data["programs"][name] = d
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
# Otherwise store relative to config root
|
||||
try:
|
||||
d["source"] = str(Path(d["source"]).relative_to(config.root))
|
||||
d["source"] = str(src.relative_to(config.root))
|
||||
except ValueError:
|
||||
pass # not under root — keep absolute
|
||||
data["programs"][name] = d
|
||||
@@ -261,7 +298,9 @@ def save_config(config: CastleConfig) -> None:
|
||||
def ensure_dirs() -> None:
|
||||
"""Ensure castle directories exist."""
|
||||
CASTLE_HOME.mkdir(parents=True, exist_ok=True)
|
||||
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CODE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
SPECS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CONTENT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
SECRETS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
STATIC_DIR.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(SECRETS_DIR, 0o700)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from castle_core.config import GENERATED_DIR, STATIC_DIR
|
||||
from castle_core.config import CONTENT_DIR, SPECS_DIR
|
||||
from castle_core.registry import NodeRegistry
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ def generate_caddyfile_from_registry(
|
||||
) -> str:
|
||||
"""Generate Caddyfile from the node registry.
|
||||
|
||||
Static files served from ~/.castle/static/castle-app/.
|
||||
Static files served from ~/.castle/artifacts/content/castle-app/.
|
||||
No repo-relative paths.
|
||||
|
||||
If remote_registries is provided, cross-node routes are added for
|
||||
@@ -49,8 +49,28 @@ def generate_caddyfile_from_registry(
|
||||
lines.append(" }")
|
||||
lines.append("")
|
||||
|
||||
# SPA from static dir
|
||||
static_app = STATIC_DIR / "castle-app"
|
||||
# Static frontends from ~/.castle/static/<name>/
|
||||
# Any directory with an index.html gets served as a SPA at its name prefix.
|
||||
# castle-app is special-cased to serve at the root (no prefix).
|
||||
if CONTENT_DIR.is_dir():
|
||||
for app_dir in sorted(CONTENT_DIR.iterdir()):
|
||||
if not app_dir.is_dir() or not (app_dir / "index.html").exists():
|
||||
continue
|
||||
if app_dir.name == "castle-app":
|
||||
continue # handled below as root fallback
|
||||
path_prefix = f"/{app_dir.name}"
|
||||
if path_prefix in local_paths:
|
||||
continue
|
||||
local_paths.add(path_prefix)
|
||||
lines.append(f" handle_path {path_prefix}/* {{")
|
||||
lines.append(f" root * {app_dir}")
|
||||
lines.append(" try_files {path} /index.html")
|
||||
lines.append(" file_server")
|
||||
lines.append(" }")
|
||||
lines.append("")
|
||||
|
||||
# castle-app SPA at root (fallback)
|
||||
static_app = CONTENT_DIR / "castle-app"
|
||||
if (static_app / "index.html").exists():
|
||||
lines.append(" handle {")
|
||||
lines.append(f" root * {static_app}")
|
||||
@@ -58,7 +78,7 @@ def generate_caddyfile_from_registry(
|
||||
lines.append(" file_server")
|
||||
lines.append(" }")
|
||||
else:
|
||||
fallback = GENERATED_DIR / "app"
|
||||
fallback = SPECS_DIR / "app"
|
||||
lines.append(" handle / {")
|
||||
lines.append(f" root * {fallback}")
|
||||
lines.append(" file_server")
|
||||
|
||||
@@ -8,10 +8,10 @@ from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from castle_core.config import CASTLE_HOME
|
||||
from castle_core.config import CONTENT_DIR, SPECS_DIR
|
||||
|
||||
REGISTRY_PATH = CASTLE_HOME / "registry.yaml"
|
||||
STATIC_DIR = CASTLE_HOME / "static"
|
||||
REGISTRY_PATH = SPECS_DIR / "registry.yaml"
|
||||
STATIC_DIR = CONTENT_DIR # backwards-compat alias
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -9,7 +9,7 @@ import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from castle_core.config import STATIC_DIR
|
||||
from castle_core.config import CONTENT_DIR
|
||||
from castle_core.manifest import ProgramSpec
|
||||
|
||||
DEV_ACTIONS = ["build", "test", "lint", "type-check", "check"]
|
||||
@@ -214,19 +214,19 @@ class ReactViteHandler(StackHandler):
|
||||
for output_dir in outputs:
|
||||
src_path = src / output_dir
|
||||
if src_path.exists():
|
||||
dest = STATIC_DIR / name
|
||||
dest = CONTENT_DIR / name
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
shutil.copytree(src_path, dest)
|
||||
|
||||
return ActionResult(
|
||||
component=name, action="install", status="ok",
|
||||
output=f"Built and deployed to {STATIC_DIR / name}",
|
||||
output=f"Built and deployed to {CONTENT_DIR / name}",
|
||||
)
|
||||
|
||||
async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
"""Remove static assets from ~/.castle/static/{name}/."""
|
||||
dest = STATIC_DIR / name
|
||||
dest = CONTENT_DIR / name
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
return ActionResult(
|
||||
|
||||
@@ -12,9 +12,9 @@ from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_static_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Use a temp dir for STATIC_DIR so tests don't depend on real ~/.castle."""
|
||||
monkeypatch.setattr(caddyfile_mod, "STATIC_DIR", tmp_path / "static")
|
||||
def _isolate_content_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Use a temp dir for CONTENT_DIR so tests don't depend on real ~/.castle."""
|
||||
monkeypatch.setattr(caddyfile_mod, "CONTENT_DIR", tmp_path / "content")
|
||||
|
||||
|
||||
def _make_registry(
|
||||
|
||||
@@ -49,11 +49,11 @@ class TestUnitFromDeployed:
|
||||
deployed = DeployedComponent(
|
||||
runner="python",
|
||||
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
|
||||
env={"TEST_SVC_DATA_DIR": "/data/castle/test-svc"},
|
||||
env={"TEST_SVC_DATA_DIR": "/home/user/.castle/data/test-svc"},
|
||||
description="Test service",
|
||||
)
|
||||
unit = generate_unit_from_deployed("test-svc", deployed)
|
||||
assert "Environment=TEST_SVC_DATA_DIR=/data/castle/test-svc" in unit
|
||||
assert "Environment=TEST_SVC_DATA_DIR=/home/user/.castle/data/test-svc" in unit
|
||||
|
||||
def test_contains_restart_policy(self) -> None:
|
||||
"""Unit file has restart configuration."""
|
||||
@@ -82,14 +82,14 @@ class TestUnitFromDeployed:
|
||||
deployed = DeployedComponent(
|
||||
runner="python",
|
||||
run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"],
|
||||
env={"MY_SVC_PORT": "9001", "MY_SVC_DATA_DIR": "/data/castle/my-svc"},
|
||||
env={"MY_SVC_PORT": "9001", "MY_SVC_DATA_DIR": "/home/user/.castle/data/my-svc"},
|
||||
description="My service",
|
||||
)
|
||||
unit = generate_unit_from_deployed("my-svc", deployed)
|
||||
assert "Description=Castle: My service" in unit
|
||||
assert "ExecStart=/home/user/.local/bin/uv run my-svc" in unit
|
||||
assert "Environment=MY_SVC_PORT=9001" in unit
|
||||
assert "Environment=MY_SVC_DATA_DIR=/data/castle/my-svc" in unit
|
||||
assert "Environment=MY_SVC_DATA_DIR=/home/user/.castle/data/my-svc" in unit
|
||||
assert "WorkingDirectory" not in unit
|
||||
assert "Restart=on-failure" in unit
|
||||
|
||||
@@ -111,7 +111,7 @@ class TestUnitFromDeployed:
|
||||
deployed = DeployedComponent(
|
||||
runner="python",
|
||||
run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"],
|
||||
env={"DATA_DIR": "/data/castle/my-svc"},
|
||||
env={"DATA_DIR": "/home/user/.castle/data/my-svc"},
|
||||
description="Test",
|
||||
)
|
||||
unit = generate_unit_from_deployed("my-svc", deployed)
|
||||
|
||||
Reference in New Issue
Block a user