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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
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.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user