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 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 <source>/<dist>) — 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/<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]:
"""Exact set of unit filenames that should exist on disk for this registry."""
files: set[str] = set()

View File

@@ -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/<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("")
# 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 <source>/<build.outputs[0]> (no central copy). castle-app is the root app
# (served at /); other static frontends mount at /<name>.
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

View File

@@ -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)

View File

@@ -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
<source>/<build.outputs[0]> — 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)
"""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"Removed {dest}",
)
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.",
)

View File

@@ -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(

View File

@@ -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"):
# 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