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