From 482524bfe5eee9f3f38b57a089ae56a2bb339151 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Sat, 13 Jun 2026 18:16:09 -0700 Subject: [PATCH] Phase 2b: serve static frontends in place from repo dist The Caddyfile generator now emits static-frontend routes that root directly at / (e.g. /data/repos/castle/app/dist) instead of a copy under ~/.castle/artifacts/content. Removes _copy_app_static and the central content-dir staging entirely; rebuilds are live without a copy step. - caddyfile.py: manifest-driven static routes (castle-app at /, others at /) - ReactViteHandler.install just builds in place (no copy); uninstall is a no-op - is_active(static frontend) = repo dist exists - test isolation updated for the generator's config use Dashboard + power-graph-app verified serving from repo dist; 168 tests pass. --- core/src/castle_core/deploy.py | 23 +------ core/src/castle_core/generators/caddyfile.py | 70 +++++++++++++------- core/src/castle_core/lifecycle.py | 7 +- core/src/castle_core/stacks.py | 33 +++------ core/tests/test_caddyfile.py | 14 ++-- core/tests/test_lifecycle.py | 23 ++++--- 6 files changed, 86 insertions(+), 84 deletions(-) diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py index 7f568bf..6d66b14 100644 --- a/core/src/castle_core/deploy.py +++ b/core/src/castle_core/deploy.py @@ -14,7 +14,6 @@ from dataclasses import dataclass, field from pathlib import Path from castle_core.config import ( - CONTENT_DIR, DATA_DIR, SPECS_DIR, CastleConfig, @@ -98,8 +97,8 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe result.deployed_count += 1 result.messages.append(_format_deployed(name, deployed)) - # Handle frontend build artifacts - _copy_app_static(config, result.messages) + # Static frontends are served in place from their repo build output + # (the Caddyfile roots directly at /) — no copy step. # Save registry save_registry(registry) @@ -367,24 +366,6 @@ def _format_deployed(name: str, deployed: DeployedComponent) -> str: return " ".join(parts) -def _copy_app_static(config: CastleConfig, messages: list[str]) -> None: - """Copy frontend build outputs to ~/.castle/artifacts/content//.""" - for name, comp in config.programs.items(): - if comp.behavior != "frontend": - continue - if not (comp.build and comp.build.outputs): - continue - source_dir = comp.source_dir or name - for output in comp.build.outputs: - src = config.root / source_dir / output - if src.exists(): - dest = CONTENT_DIR / name - if dest.exists(): - shutil.rmtree(dest) - shutil.copytree(src, dest) - messages.append(f"Static: {src} → {dest}") - - def _desired_unit_files(registry: NodeRegistry) -> set[str]: """Exact set of unit filenames that should exist on disk for this registry.""" files: set[str] = set() diff --git a/core/src/castle_core/generators/caddyfile.py b/core/src/castle_core/generators/caddyfile.py index de3075f..b3df316 100644 --- a/core/src/castle_core/generators/caddyfile.py +++ b/core/src/castle_core/generators/caddyfile.py @@ -2,7 +2,9 @@ from __future__ import annotations -from castle_core.config import CONTENT_DIR, SPECS_DIR +from pathlib import Path + +from castle_core.config import SPECS_DIR from castle_core.registry import NodeRegistry @@ -53,31 +55,15 @@ def generate_caddyfile_from_registry( lines.append(" }") lines.append("") - # Static frontends from ~/.castle/static// - # 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("") + # Static frontends — served IN PLACE from each program's repo build output. + # A behavior=frontend program with no service is static; Caddy roots directly + # at / (no central copy). castle-app is the root app + # (served at /); other static frontends mount at /. + root_serve = _root_static_serve(lines, local_paths) - # castle-app SPA at root (fallback) - static_app = CONTENT_DIR / "castle-app" - if (static_app / "index.html").exists(): + if root_serve is not None: lines.append(" handle {") - lines.append(f" root * {static_app}") + lines.append(f" root * {root_serve}") lines.append(" try_files {path} /index.html") lines.append(" file_server") lines.append(" }") @@ -90,3 +76,39 @@ def generate_caddyfile_from_registry( lines.append("}") return "\n".join(lines) + + +def _root_static_serve(lines: list[str], local_paths: set[str]) -> Path | None: + """Emit handle_path blocks for non-root static frontends; return the root app's + serve dir (castle-app), or None. Static frontends are served from their repo + build output in place — no copy into a central content dir.""" + try: + from castle_core.config import load_config + + config = load_config() + except Exception: + return None + + root_serve: Path | None = None + for name, prog in sorted(config.programs.items()): + if prog.behavior != "frontend" or not prog.source: + continue + if not (prog.build and prog.build.outputs): + continue + if name in config.services: # self-serving frontend → handled as a proxy route + continue + serve_dir = Path(prog.source) / prog.build.outputs[0] + if name == "castle-app": + root_serve = serve_dir + continue + path_prefix = f"/{name}" + if path_prefix in local_paths: + continue + local_paths.add(path_prefix) + lines.append(f" handle_path {path_prefix}/* {{") + lines.append(f" root * {serve_dir}") + lines.append(" try_files {path} /index.html") + lines.append(" file_server") + lines.append(" }") + lines.append("") + return root_serve diff --git a/core/src/castle_core/lifecycle.py b/core/src/castle_core/lifecycle.py index a581ebd..6321a56 100644 --- a/core/src/castle_core/lifecycle.py +++ b/core/src/castle_core/lifecycle.py @@ -15,7 +15,7 @@ import shutil import subprocess from pathlib import Path -from castle_core.config import CONTENT_DIR, CastleConfig +from castle_core.config import CastleConfig from castle_core.generators.systemd import ( SYSTEMD_USER_DIR, generate_timer, @@ -68,7 +68,10 @@ def is_active(name: str, config: CastleConfig) -> bool: if name in config.jobs: return _systemctl_active(timer_name(name)) if _is_static_frontend(name, config): - return (CONTENT_DIR / name).is_dir() + comp = config.programs[name] + if comp.source and comp.build and comp.build.outputs: + return (Path(comp.source) / comp.build.outputs[0]).is_dir() + return False comp = config.programs.get(name) if comp is not None and comp.source: return _on_path(name) diff --git a/core/src/castle_core/stacks.py b/core/src/castle_core/stacks.py index 9198374..7731b2a 100644 --- a/core/src/castle_core/stacks.py +++ b/core/src/castle_core/stacks.py @@ -4,12 +4,10 @@ from __future__ import annotations import asyncio import os -import shutil import tomllib from dataclasses import dataclass from pathlib import Path -from castle_core.config import CONTENT_DIR from castle_core.manifest import ProgramSpec DEV_ACTIONS = ["build", "test", "lint", "type-check", "check", "run"] @@ -200,47 +198,34 @@ class ReactViteHandler(StackHandler): ) async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: - """Build and copy static assets to ~/.castle/static/{name}/.""" + """Build the static assets in place. The gateway serves them directly from + / — no copy into a central content dir.""" result = await self.build(name, comp, root) if result.status != "ok": return ActionResult( component=name, action="install", status="error", output=f"Build failed:\n{result.output}", ) - - src = _source_dir(comp, root) outputs = comp.build.outputs if comp.build else [] if not outputs: return ActionResult( component=name, action="install", status="error", output="No build outputs configured.", ) - - for output_dir in outputs: - src_path = src / output_dir - if src_path.exists(): - dest = CONTENT_DIR / name - if dest.exists(): - shutil.rmtree(dest) - shutil.copytree(src_path, dest) - + dist = _source_dir(comp, root) / outputs[0] return ActionResult( component=name, action="install", status="ok", - output=f"Built and deployed to {CONTENT_DIR / name}", + output=f"Built; served in place from {dist}", ) async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: - """Remove static assets from ~/.castle/static/{name}/.""" - dest = CONTENT_DIR / name - if dest.exists(): - shutil.rmtree(dest) - return ActionResult( - component=name, action="uninstall", status="ok", - output=f"Removed {dest}", - ) + """Static frontends have no install footprint to remove (served in place). + + Deactivating one means dropping its gateway route — handled by removing the + program from the registry, not by deleting build output.""" return ActionResult( component=name, action="uninstall", status="ok", - output=f"Nothing to remove ({dest} does not exist)", + output=f"{name}: served in place; nothing to uninstall.", ) diff --git a/core/tests/test_caddyfile.py b/core/tests/test_caddyfile.py index 6afabb4..cd0a0a4 100644 --- a/core/tests/test_caddyfile.py +++ b/core/tests/test_caddyfile.py @@ -2,19 +2,23 @@ from __future__ import annotations -from pathlib import Path import pytest -import castle_core.generators.caddyfile as caddyfile_mod from castle_core.generators.caddyfile import generate_caddyfile_from_registry from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry @pytest.fixture(autouse=True) -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 _isolate_config(monkeypatch: pytest.MonkeyPatch) -> None: + """Isolate the generator from the real ~/.castle config so static-frontend + routes don't leak into these registry-focused tests.""" + import castle_core.config as config_mod + + def _no_config(*args: object, **kwargs: object) -> object: + raise FileNotFoundError("isolated in tests") + + monkeypatch.setattr(config_mod, "load_config", _no_config) def _make_registry( diff --git a/core/tests/test_lifecycle.py b/core/tests/test_lifecycle.py index 69a4af2..55157f2 100644 --- a/core/tests/test_lifecycle.py +++ b/core/tests/test_lifecycle.py @@ -34,14 +34,21 @@ class TestIsActive: config = load_config(castle_root) assert lifecycle.is_active("does-not-exist", config) is False - def test_static_frontend_checks_content_dir(self, castle_root: Path, tmp_path: Path) -> None: + def test_static_frontend_active_when_dist_built(self, castle_root: Path, tmp_path: Path) -> None: + from castle_core.manifest import BuildSpec + config = load_config(castle_root) + repo = tmp_path / "fe" config.programs["fe"] = config.programs["test-tool"].model_copy( - update={"id": "fe", "behavior": "frontend", "source": "/tmp/fe"} + update={ + "id": "fe", + "behavior": "frontend", + "source": str(repo), + "build": BuildSpec(commands=[["pnpm", "build"]], outputs=["dist"]), + } ) - content = tmp_path / "content" - (content / "fe").mkdir(parents=True) - with patch.object(lifecycle, "CONTENT_DIR", content): - assert lifecycle.is_active("fe", config) is True - with patch.object(lifecycle, "CONTENT_DIR", tmp_path / "empty"): - assert lifecycle.is_active("fe", config) is False + # No dist yet → inactive + assert lifecycle.is_active("fe", config) is False + # Built dist → served in place → active + (repo / "dist").mkdir(parents=True) + assert lifecycle.is_active("fe", config) is True