add fixture-invoicing example, sample-room wrap, kind cluster support

- examples/fixture-invoicing/: FastAPI + Vue + Postgres demo (4-entity invoice fixture)
- cfg/sample/: wraps the fixture (managed.repos points at examples/)
- ctrl/kind-{up,down,status}.sh + per-room k8s render in soleprint/ctrl/k8s/
- build.py: relative repo paths, resilient rmtree, optional k8s render hook
- cfg/.gitignore: stop ignoring sample/ and standalone/ template rooms

Manifests render cleanly but kind cluster has not been run end-to-end yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 05:30:52 -03:00
parent b886455431
commit 5f9cac1947
78 changed files with 3025 additions and 201 deletions

View File

@@ -26,6 +26,8 @@ import argparse
import json
import logging
import shutil
import stat
import subprocess
import sys
from pathlib import Path
@@ -48,6 +50,35 @@ def ensure_dir(path: Path):
path.mkdir(parents=True, exist_ok=True)
def _rmtree_resilient(path: Path):
"""Remove path tree, tolerating root-owned files written by containers.
Docker containers that mount gen/ as a volume sometimes write files as
root (e.g. __pycache__). A plain shutil.rmtree then fails with EACCES.
We first try shutil.rmtree; if that hits a PermissionError we fall back
to deleting the offending files from inside an ephemeral alpine container.
"""
def _chmod_and_retry(func, target, exc_info):
try:
Path(target).chmod(stat.S_IWUSR | stat.S_IRUSR | stat.S_IXUSR)
func(target)
except Exception:
raise
try:
shutil.rmtree(path, onerror=_chmod_and_retry)
return
except PermissionError:
pass
log.info(" (falling back to docker-based cleanup)")
subprocess.run(
["docker", "run", "--rm", "-v", f"{path.parent}:/work",
"alpine:3", "sh", "-c", f"rm -rf /work/{path.name}"],
check=True,
)
def copy_path(source: Path, target: Path, quiet: bool = False):
"""Copy file or directory, resolving symlinks."""
if target.is_symlink():
@@ -162,9 +193,11 @@ def build_managed(output_dir: Path, cfg_name: str, config: dict):
log.info(f"Building managed ({managed_name})...")
# Copy repos
# Copy repos (relative paths resolve from SPR_ROOT)
for repo_name, repo_path in repos.items():
source = Path(repo_path)
if not source.is_absolute():
source = SPR_ROOT / source
target = managed_dir / repo_name
if copy_repo(source, target):
log.info(f" {repo_name}/")
@@ -332,7 +365,7 @@ def build(output_dir: Path, cfg_name: str | None = None, clean: bool = True):
# Clean output directory first
if clean and output_dir.exists():
log.info(f"Cleaning {output_dir}...")
shutil.rmtree(output_dir)
_rmtree_resilient(output_dir)
ensure_dir(output_dir)
@@ -349,6 +382,15 @@ def build(output_dir: Path, cfg_name: str | None = None, clean: bool = True):
# Standalone: everything in output_dir
build_soleprint(output_dir, room)
# Layer 7 (optional): render kind-cluster manifests
try:
from soleprint.ctrl.k8s import render_k8s
from soleprint.ctrl.k8s.render import k8s_enabled
if k8s_enabled(config):
render_k8s(room=room, config=config, gen_dir=output_dir)
except ImportError as e:
log.warning(f"k8s rendering unavailable: {e}")
log.info(f"\n✓ Built: {output_dir}")