Phase 2b: serve static frontends in place from repo dist

The Caddyfile generator now emits static-frontend routes that root directly at
<source>/<build.outputs[0]> (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 /<name>)
- 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.
This commit is contained in:
2026-06-13 18:16:09 -07:00
parent 2953aea548
commit 482524bfe5
6 changed files with 86 additions and 84 deletions

View File

@@ -14,7 +14,6 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from castle_core.config import ( from castle_core.config import (
CONTENT_DIR,
DATA_DIR, DATA_DIR,
SPECS_DIR, SPECS_DIR,
CastleConfig, CastleConfig,
@@ -98,8 +97,8 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe
result.deployed_count += 1 result.deployed_count += 1
result.messages.append(_format_deployed(name, deployed)) result.messages.append(_format_deployed(name, deployed))
# Handle frontend build artifacts # Static frontends are served in place from their repo build output
_copy_app_static(config, result.messages) # (the Caddyfile roots directly at <source>/<dist>) — no copy step.
# Save registry # Save registry
save_registry(registry) save_registry(registry)
@@ -367,24 +366,6 @@ def _format_deployed(name: str, deployed: DeployedComponent) -> str:
return " ".join(parts) return " ".join(parts)
def _copy_app_static(config: CastleConfig, messages: list[str]) -> None:
"""Copy frontend build outputs to ~/.castle/artifacts/content/<name>/."""
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]: def _desired_unit_files(registry: NodeRegistry) -> set[str]:
"""Exact set of unit filenames that should exist on disk for this registry.""" """Exact set of unit filenames that should exist on disk for this registry."""
files: set[str] = set() files: set[str] = set()

View File

@@ -2,7 +2,9 @@
from __future__ import annotations 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 from castle_core.registry import NodeRegistry
@@ -53,31 +55,15 @@ def generate_caddyfile_from_registry(
lines.append(" }") lines.append(" }")
lines.append("") lines.append("")
# Static frontends from ~/.castle/static/<name>/ # Static frontends — served IN PLACE from each program's repo build output.
# Any directory with an index.html gets served as a SPA at its name prefix. # A behavior=frontend program with no service is static; Caddy roots directly
# castle-app is special-cased to serve at the root (no prefix). # at <source>/<build.outputs[0]> (no central copy). castle-app is the root app
if CONTENT_DIR.is_dir(): # (served at /); other static frontends mount at /<name>.
for app_dir in sorted(CONTENT_DIR.iterdir()): root_serve = _root_static_serve(lines, local_paths)
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) if root_serve is not None:
static_app = CONTENT_DIR / "castle-app"
if (static_app / "index.html").exists():
lines.append(" handle {") lines.append(" handle {")
lines.append(f" root * {static_app}") lines.append(f" root * {root_serve}")
lines.append(" try_files {path} /index.html") lines.append(" try_files {path} /index.html")
lines.append(" file_server") lines.append(" file_server")
lines.append(" }") lines.append(" }")
@@ -90,3 +76,39 @@ def generate_caddyfile_from_registry(
lines.append("}") lines.append("}")
return "\n".join(lines) 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

View File

@@ -15,7 +15,7 @@ import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from castle_core.config import CONTENT_DIR, CastleConfig from castle_core.config import CastleConfig
from castle_core.generators.systemd import ( from castle_core.generators.systemd import (
SYSTEMD_USER_DIR, SYSTEMD_USER_DIR,
generate_timer, generate_timer,
@@ -68,7 +68,10 @@ def is_active(name: str, config: CastleConfig) -> bool:
if name in config.jobs: if name in config.jobs:
return _systemctl_active(timer_name(name)) return _systemctl_active(timer_name(name))
if _is_static_frontend(name, config): 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) comp = config.programs.get(name)
if comp is not None and comp.source: if comp is not None and comp.source:
return _on_path(name) return _on_path(name)

View File

@@ -4,12 +4,10 @@ from __future__ import annotations
import asyncio import asyncio
import os import os
import shutil
import tomllib import tomllib
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from castle_core.config import CONTENT_DIR
from castle_core.manifest import ProgramSpec from castle_core.manifest import ProgramSpec
DEV_ACTIONS = ["build", "test", "lint", "type-check", "check", "run"] 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: 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
<source>/<build.outputs[0]> — no copy into a central content dir."""
result = await self.build(name, comp, root) result = await self.build(name, comp, root)
if result.status != "ok": if result.status != "ok":
return ActionResult( return ActionResult(
component=name, action="install", status="error", component=name, action="install", status="error",
output=f"Build failed:\n{result.output}", output=f"Build failed:\n{result.output}",
) )
src = _source_dir(comp, root)
outputs = comp.build.outputs if comp.build else [] outputs = comp.build.outputs if comp.build else []
if not outputs: if not outputs:
return ActionResult( return ActionResult(
component=name, action="install", status="error", component=name, action="install", status="error",
output="No build outputs configured.", output="No build outputs configured.",
) )
dist = _source_dir(comp, root) / outputs[0]
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)
return ActionResult( return ActionResult(
component=name, action="install", status="ok", 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: async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
"""Remove static assets from ~/.castle/static/{name}/.""" """Static frontends have no install footprint to remove (served in place).
dest = CONTENT_DIR / name
if dest.exists(): Deactivating one means dropping its gateway route — handled by removing the
shutil.rmtree(dest) program from the registry, not by deleting build output."""
return ActionResult( return ActionResult(
component=name, action="uninstall", status="ok", component=name, action="uninstall", status="ok",
output=f"Removed {dest}", output=f"{name}: served in place; nothing to uninstall.",
)
return ActionResult(
component=name, action="uninstall", status="ok",
output=f"Nothing to remove ({dest} does not exist)",
) )

View File

@@ -2,19 +2,23 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
import pytest import pytest
import castle_core.generators.caddyfile as caddyfile_mod
from castle_core.generators.caddyfile import generate_caddyfile_from_registry from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _isolate_content_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: def _isolate_config(monkeypatch: pytest.MonkeyPatch) -> None:
"""Use a temp dir for CONTENT_DIR so tests don't depend on real ~/.castle.""" """Isolate the generator from the real ~/.castle config so static-frontend
monkeypatch.setattr(caddyfile_mod, "CONTENT_DIR", tmp_path / "content") 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( def _make_registry(

View File

@@ -34,14 +34,21 @@ class TestIsActive:
config = load_config(castle_root) config = load_config(castle_root)
assert lifecycle.is_active("does-not-exist", config) is False 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) config = load_config(castle_root)
repo = tmp_path / "fe"
config.programs["fe"] = config.programs["test-tool"].model_copy( 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" # No dist yet → inactive
(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 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