Rename Castle -> Wild PC across the repo

Repo-side rename only (Phases 1-3 of the migration plan); the live box
(~/.castle, systemd units, /data/castle, domains) is a separate cutover.

- Slug `castle` -> `wildpc`: CLI command, module names (wildpc_core/cli/api),
  dist names, entry point `wildpc = wildpc_cli.main:main`.
- Identifiers: CastleConfig/NATSClient/DirError/MDNS -> Wildpc*.
- Env/constants: CASTLE_* -> WILDPC_*; ~/.castle -> ~/.wildpc, castle.yaml ->
  wildpc.yaml, /data/castle -> /data/wildpc.
- Systemd UNIT_PREFIX castle- -> wildpc-; own programs castle-api/gateway/etc.
- Display prose "Castle" -> "Wild PC" in docs, agent-guide files, README, frontend.
- Package dirs and bootstrap yaml renamed via git mv; lockfiles regenerated;
  redundant nested uv.lock files dropped (workspace root lock is authoritative).

Tests: core 273, cli 47, wildpc-api 120 all pass. Frontend type-checks + builds.
Fixed a stale test fixture (secret_env_path kind arg) broken pre-rename.
This commit is contained in:
2026-07-18 22:55:08 -07:00
parent 25d7a522cc
commit 05b28cb584
190 changed files with 2428 additions and 3337 deletions

View File

@@ -1,7 +1,7 @@
[project]
name = "castle-core"
name = "wildpc-core"
version = "0.1.0"
description = "Castle platform core library - manifest models, config, and generators"
description = "Wild PC platform core library - manifest models, config, and generators"
requires-python = ">=3.11"
dependencies = [
"pyyaml>=6.0.0",
@@ -14,7 +14,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/castle_core"]
packages = ["src/wildpc_core"]
[dependency-groups]
dev = [
@@ -23,4 +23,4 @@ dev = [
]
[tool.ruff.lint.isort]
known-first-party = ["castle_core"]
known-first-party = ["wildpc_core"]

View File

@@ -1,3 +0,0 @@
"""Castle core library - manifest models, configuration, and generators."""
__version__ = "0.1.0"

View File

@@ -0,0 +1,3 @@
"""Wild PC core library - manifest models, configuration, and generators."""
__version__ = "0.1.0"

View File

@@ -1,4 +1,4 @@
"""Adopt an existing repo as a program — shared by the CLI (`castle program add`)
"""Adopt an existing repo as a program — shared by the CLI (`wildpc program add`)
and the API (`POST /programs/adopt`).
`create` scaffolds new code from a stack; `add` adopts code that already exists
@@ -13,8 +13,8 @@ import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from castle_core.config import CastleConfig
from castle_core.manifest import BuildSpec, CommandsSpec, ProgramSpec
from wildpc_core.config import WildpcConfig
from wildpc_core.manifest import BuildSpec, CommandsSpec, ProgramSpec
class AdoptError(ValueError):
@@ -26,7 +26,7 @@ def is_git_url(s: str) -> bool:
def looks_like_program(src: Path) -> bool:
"""Whether a directory holds something castle can adopt (a project manifest or
"""Whether a directory holds something wildpc can adopt (a project manifest or
a git repo). Used to flag candidates in the filesystem browser."""
return (
(src / ".git").exists()
@@ -95,7 +95,7 @@ class Adopted:
def build_adopted_program(
config: CastleConfig,
config: WildpcConfig,
target: str,
name: str | None = None,
description: str = "",
@@ -111,7 +111,7 @@ def build_adopted_program(
if is_git_url(target):
repo_url = target
name = name or Path(target.rstrip("/")).name.removesuffix(".git")
# Default local clone location; cloned later via `castle clone`.
# Default local clone location; cloned later via `wildpc clone`.
source = str(config.repos_dir / name)
src_path = Path(source)
else:
@@ -122,11 +122,11 @@ def build_adopted_program(
name = name or src_path.name
if name in config.programs or config.deployments_named(name):
raise AdoptError(f"'{name}' already exists in castle.yaml")
raise AdoptError(f"'{name}' already exists in wildpc.yaml")
# Detect verbs from the working copy if we have one on disk. `kind` is derived
# from a deployment, not stored on the program — so adoption takes source only;
# declare a deployment separately (castle service/job create).
# declare a deployment separately (wildpc service/job create).
stack: str | None = None
detected: dict[str, list[list[str]]] = {}
if src_path.exists():

View File

@@ -1,7 +1,7 @@
"""Consumption audit — *suggests* undeclared ``requires`` by matching a
deployment's env endpoint values against known provider sockets.
This is the one place castle looks at env dependency, and it does so **only to
This is the one place wildpc looks at env dependency, and it does so **only to
propose a declaration the user confirms** never to write, and never to feed the
graph or ``functional?``. The relationship graph stays strictly declaration-derived
(see docs/relationships.md, "Env is derived *from* requires, never scraped *into*
@@ -15,8 +15,8 @@ from __future__ import annotations
import re
from dataclasses import dataclass
from castle_core.config import CastleConfig
from castle_core.relations import build_model
from wildpc_core.config import WildpcConfig
from wildpc_core.relations import build_model
# Hosts that mean "a provider on this node" — a port match against them is a strong
# signal (ports are unique per host). 172.17.0.1 is the docker bridge to the host.
@@ -62,13 +62,13 @@ def _resolve(
out.append(Suggestion(consumer, pname, env_var, f"{host}:{port}", proto))
def suggest_consumption(config: CastleConfig) -> list[Suggestion]:
def suggest_consumption(config: WildpcConfig) -> list[Suggestion]:
"""Undeclared consumption suggestions, derived from env endpoint values.
Two shapes are recognized in a deployment's ``defaults.env``:
(a) ``host:port`` inside a single value (a URL, a ``DATABASE_URL``); and
(b) a split ``X_HOST`` + ``X_PORT`` pair (e.g. ``CASTLE_API_MQTT_HOST`` +
``CASTLE_API_MQTT_PORT``). When the port resolves to a *local* provider's socket
(b) a split ``X_HOST`` + ``X_PORT`` pair (e.g. ``WILDPC_API_MQTT_HOST`` +
``WILDPC_API_MQTT_PORT``). When the port resolves to a *local* provider's socket
and the consumer doesn't already declare it, propose the edge — deduped per
(consumer, provider). A bare ``*_PORT`` with no ``*_HOST`` is ignored: without an
explicit host it can't be told apart from the deployment's own listen port."""

View File

@@ -1,4 +1,4 @@
"""Castle configuration and registry management."""
"""Wild PC configuration and registry management."""
from __future__ import annotations
@@ -12,9 +12,9 @@ import yaml
from pydantic import BaseModel, TypeAdapter
if TYPE_CHECKING:
from castle_core.secret_backends import SecretBackend
from wildpc_core.secret_backends import SecretBackend
from castle_core.manifest import (
from wildpc_core.manifest import (
AgentSpec,
DeploymentSpec,
ProgramSpec,
@@ -26,24 +26,24 @@ from castle_core.manifest import (
_DEPLOYMENT_ADAPTER: TypeAdapter[DeploymentSpec] = TypeAdapter(DeploymentSpec)
def _resolve_castle_home() -> Path:
"""Resolve the castle home directory (config, code, artifacts, secrets).
def _resolve_wildpc_home() -> Path:
"""Resolve the wildpc home directory (config, code, artifacts, secrets).
Defaults to ~/.castle. Override with the CASTLE_HOME environment variable
Defaults to ~/.wildpc. Override with the WILDPC_HOME environment variable
(supports ~ and relative paths, which are expanded and made absolute).
"""
override = os.environ.get("CASTLE_HOME")
override = os.environ.get("WILDPC_HOME")
if override:
return Path(override).expanduser().resolve()
return Path.home() / ".castle"
return Path.home() / ".wildpc"
_DEFAULT_DATA_DIR = Path("/data/castle")
_DEFAULT_DATA_DIR = Path("/data/wildpc")
_DEFAULT_REPOS_DIR = Path("/data/repos")
class CastleDirError(RuntimeError):
"""A required castle directory can't be created (e.g. data_dir outside a writable
class WildpcDirError(RuntimeError):
"""A required wildpc directory can't be created (e.g. data_dir outside a writable
parent). Carries an actionable message; surfaced to the CLI and the api instead of
a bare PermissionError traceback."""
@@ -51,10 +51,10 @@ class CastleDirError(RuntimeError):
def _resolve_root_path(
env_var: str, yaml_value: object, anchor: Path, default: Path
) -> Path:
"""Resolve a configurable root with precedence: env var > castle.yaml > default.
"""Resolve a configurable root with precedence: env var > wildpc.yaml > default.
`~` is expanded; a relative path is anchored to `anchor` (the dir containing
castle.yaml) never cwd, so the CLI (shell cwd) and the api service (unit cwd)
wildpc.yaml) never cwd, so the CLI (shell cwd) and the api service (unit cwd)
resolve identically. The built-in default is returned as-is (so it compares equal
for the "persist only when non-default" check in save_config)."""
raw = os.environ.get(env_var) or yaml_value
@@ -66,29 +66,29 @@ def _resolve_root_path(
return p.resolve()
CASTLE_HOME = _resolve_castle_home()
CODE_DIR = CASTLE_HOME / "code"
ARTIFACTS_DIR = CASTLE_HOME / "artifacts"
WILDPC_HOME = _resolve_wildpc_home()
CODE_DIR = WILDPC_HOME / "code"
ARTIFACTS_DIR = WILDPC_HOME / "artifacts"
SPECS_DIR = ARTIFACTS_DIR / "specs"
CONTENT_DIR = ARTIFACTS_DIR / "content"
SECRETS_DIR = CASTLE_HOME / "secrets"
# data_dir and repos_dir are deliberately NOT module constants. Unlike the CASTLE_HOME
# family above (env-or-default — the dir that *holds* castle.yaml can't be configured
# inside it), these are per-instance settings read from castle.yaml. A module global
SECRETS_DIR = WILDPC_HOME / "secrets"
# data_dir and repos_dir are deliberately NOT module constants. Unlike the WILDPC_HOME
# family above (env-or-default — the dir that *holds* wildpc.yaml can't be configured
# inside it), these are per-instance settings read from wildpc.yaml. A module global
# would be a second copy of that value, resolved once at import against one process's
# environment — exactly what let the CLI and the api service drift. They live only on
# the loaded CastleConfig; read config.data_dir / config.repos_dir (see load_config).
# the loaded WildpcConfig; read config.data_dir / config.repos_dir (see load_config).
# User tool directories — the single source of truth for "where our CLIs live".
# Used both at build time (dev-verb subprocess PATH) and at run time (generated
# systemd unit PATH) so a service sees the same tools castle used to build it.
# systemd unit PATH) so a service sees the same tools wildpc used to build it.
# Order matters: pnpm's modern standalone installer puts its shim in
# $PNPM_HOME/bin, which must win over the bare dir (older installs leave a stale
# version wrapper there). nvm/node is intentionally omitted — it's versioned and
# brittle to hardcode. A program's node is resolved per-program from its declared
# pin (.node-version/.nvmrc/engines) and prepended ahead of these dirs at both the
# build subprocess (stacks._build_env) and the runtime unit PATH (Deployment
# .path_prepend) — see castle_core.toolchains.
# .path_prepend) — see wildpc_core.toolchains.
USER_TOOL_PATH_DIRS = [
Path.home() / ".local" / "bin",
Path.home() / ".local" / "share" / "pnpm" / "bin",
@@ -102,24 +102,24 @@ GENERATED_DIR = SPECS_DIR
STATIC_DIR = CONTENT_DIR
def find_castle_root() -> Path:
"""Find the castle config root (directory containing castle.yaml).
def find_wildpc_root() -> Path:
"""Find the wildpc config root (directory containing wildpc.yaml).
Search order:
1. ~/.castle/castle.yaml (the canonical instance location)
1. ~/.wildpc/wildpc.yaml (the canonical instance location)
2. Walk up from cwd (for development/testing)
"""
# Canonical location first
if (CASTLE_HOME / "castle.yaml").exists():
return CASTLE_HOME
if (WILDPC_HOME / "wildpc.yaml").exists():
return WILDPC_HOME
# Fallback: walk up from cwd
current = Path.cwd()
while current != current.parent:
if (current / "castle.yaml").exists():
if (current / "wildpc.yaml").exists():
return current
current = current.parent
raise FileNotFoundError(
f"Could not find castle.yaml.\nExpected at: {CASTLE_HOME / 'castle.yaml'}"
f"Could not find wildpc.yaml.\nExpected at: {WILDPC_HOME / 'wildpc.yaml'}"
)
@@ -142,7 +142,7 @@ class GatewayConfig:
# names never appear in public DNS. `tunnel_id` is the cloudflared tunnel UUID.
public_domain: str | None = None
tunnel_id: str | None = None
# acme mode only: emit the `events { on cert_obtained exec castle tls reconcile }`
# acme mode only: emit the `events { on cert_obtained exec wildpc tls reconcile }`
# hook so certs materialized onto raw-TCP services refresh on renewal. Requires
# the events-exec plugin in the gateway's Caddy build — set true only once that
# Caddy is installed (see docs/tcp-exposure.md §5). Default false keeps a
@@ -150,7 +150,7 @@ class GatewayConfig:
cert_hook: bool = False
# Deployment kinds and the CastleConfig store each lives in. Kind is STRUCTURAL —
# Deployment kinds and the WildpcConfig store each lives in. Kind is STRUCTURAL —
# a deployment's identity is (name, kind), so names are unique within a kind but may
# collide across kinds (a `backup` tool + service + job coexist). `kind_for` (manifest)
# stays only to validate that a spec's manager/schedule matches the store it's in.
@@ -165,8 +165,8 @@ _KIND_STORE = {
@dataclass
class CastleConfig:
"""Full castle configuration."""
class WildpcConfig:
"""Full wildpc configuration."""
root: Path
gateway: GatewayConfig
@@ -185,7 +185,7 @@ class CastleConfig:
# Optional; empty means the API falls back to a built-in default set.
agents: dict[str, AgentSpec] = field(default_factory=dict)
# Configurable roots — the single source of truth (no module-constant twin).
# load_config sets them (env > castle.yaml > default); a bare constructor gets the
# load_config sets them (env > wildpc.yaml > default); a bare constructor gets the
# built-in defaults so tests/callers that don't care stay valid.
data_dir: Path = field(default_factory=lambda: _DEFAULT_DATA_DIR)
repos_dir: Path = field(default_factory=lambda: _DEFAULT_REPOS_DIR)
@@ -259,9 +259,9 @@ def resolve_env_split(
- ``${secret:NAME}`` resolves via the active secret backend (file or OpenBao).
- ``${port}`` / ``${data_dir}`` / ``${name}`` / ``${public_url}`` (and
anything else in ``context``) expand to castle's computed values, so a
anything else in ``context``) expand to wildpc's computed values, so a
service maps them to whatever env var its program reads (e.g.
``MY_PORT: ${port}``) without hardcoding or castle silently injecting a
``MY_PORT: ${port}``) without hardcoding or wildpc silently injecting a
guessed var name. ``${public_url}`` is the service's gateway-facing base
URL (``https://<name>.<domain>`` under acme) the origin an app allowlists.
"""
@@ -324,9 +324,9 @@ def resolve_env_vars(
def _secrets_settings() -> dict:
"""The ``secrets:`` block of castle.yaml — selects the backend."""
"""The ``secrets:`` block of wildpc.yaml — selects the backend."""
try:
data = yaml.safe_load((CASTLE_HOME / "castle.yaml").read_text()) or {}
data = yaml.safe_load((WILDPC_HOME / "wildpc.yaml").read_text()) or {}
return data.get("secrets") or {}
except Exception:
return {}
@@ -336,18 +336,18 @@ def active_secret_backend() -> SecretBackend:
"""The active secret backend (file or openbao), per ``secrets:`` + env.
The one place to get a handle for reading/writing/listing secrets, so callers
(the ``castle secret`` CLI, preflight checks) act on the *active* backend
(the ``wildpc secret`` CLI, preflight checks) act on the *active* backend
rather than guessing the filesystem the mistake that silently shadows an
OpenBao fleet's secret with a stray file.
"""
from castle_core.secret_backends import build_backend
from wildpc_core.secret_backends import build_backend
return build_backend(SECRETS_DIR, _secrets_settings())
def active_backend_name() -> str:
"""Human name of the active backend (``openbao`` | ``file``) for messages."""
return (os.environ.get("CASTLE_SECRET_BACKEND") or _secrets_settings().get("backend") or "file").lower()
return (os.environ.get("WILDPC_SECRET_BACKEND") or _secrets_settings().get("backend") or "file").lower()
def read_secret(name: str) -> str | None:
@@ -414,7 +414,7 @@ def _load_resource_dir(directory: Path) -> dict[str, dict]:
def parse_gateway(gateway_data: dict) -> GatewayConfig:
"""Build a GatewayConfig from a castle.yaml ``gateway:`` mapping.
"""Build a GatewayConfig from a wildpc.yaml ``gateway:`` mapping.
The single parser shared by ``load_config`` and the API's whole-file editor,
so a newly added gateway field can't be honored in one place and silently
@@ -433,14 +433,14 @@ def parse_gateway(gateway_data: dict) -> GatewayConfig:
)
def load_config(root: Path | None = None) -> CastleConfig:
"""Load castle config: global castle.yaml + programs/ and deployments/ dirs."""
def load_config(root: Path | None = None) -> WildpcConfig:
"""Load wildpc config: global wildpc.yaml + programs/ and deployments/ dirs."""
if root is None:
root = find_castle_root()
root = find_wildpc_root()
config_path = root / "castle.yaml"
config_path = root / "wildpc.yaml"
if not config_path.exists():
raise FileNotFoundError(f"Castle config not found: {config_path}")
raise FileNotFoundError(f"Wild PC config not found: {config_path}")
with open(config_path) as f:
data = yaml.safe_load(f) or {}
@@ -453,13 +453,13 @@ def load_config(root: Path | None = None) -> CastleConfig:
repo_path = Path(data["repo"]).expanduser()
# Configurable roots: env > this file's data_dir/repos_dir > default, anchored to
# `root` (the dir holding this castle.yaml) so a per-call load_config is correct
# regardless of the import-time constants (which resolved against CASTLE_HOME).
# `root` (the dir holding this wildpc.yaml) so a per-call load_config is correct
# regardless of the import-time constants (which resolved against WILDPC_HOME).
data_dir = _resolve_root_path(
"CASTLE_DATA_DIR", data.get("data_dir"), root, _DEFAULT_DATA_DIR
"WILDPC_DATA_DIR", data.get("data_dir"), root, _DEFAULT_DATA_DIR
)
repos_dir = _resolve_root_path(
"CASTLE_REPOS_DIR", data.get("repos_dir"), root, _DEFAULT_REPOS_DIR
"WILDPC_REPOS_DIR", data.get("repos_dir"), root, _DEFAULT_REPOS_DIR
)
programs: dict[str, ProgramSpec] = {}
@@ -468,7 +468,7 @@ def load_config(root: Path | None = None) -> CastleConfig:
# Resolve source paths to absolute
if prog.source:
if prog.source.startswith("repo:") and repo_path:
# repo:castle-api → /data/repos/castle/castle-api
# repo:wildpc-api → /data/repos/wildpc/wildpc-api
prog.source = str(repo_path / prog.source[5:])
elif not Path(prog.source).is_absolute():
prog.source = str(root / prog.source)
@@ -482,7 +482,7 @@ def load_config(root: Path | None = None) -> CastleConfig:
for name, spec in (data.get("agents") or {}).items()
}
config = CastleConfig(
config = WildpcConfig(
root=root,
repo=repo_path,
gateway=gateway,
@@ -600,7 +600,7 @@ def _spec_to_yaml_dict(spec: BaseModel) -> dict:
return cleaned if isinstance(cleaned, dict) else {}
def _program_to_yaml_dict(spec: ProgramSpec, config: CastleConfig) -> dict:
def _program_to_yaml_dict(spec: ProgramSpec, config: WildpcConfig) -> dict:
"""Serialize a ProgramSpec, rewriting absolute source paths to relative."""
d = _spec_to_yaml_dict(spec)
if d.get("source") and Path(d["source"]).is_absolute():
@@ -632,7 +632,7 @@ def _write_resource_dir(directory: Path, specs: dict[str, dict]) -> None:
path.unlink()
# Top-level castle.yaml keys that save_config owns and rewrites; anything else
# Top-level wildpc.yaml keys that save_config owns and rewrites; anything else
# (e.g. `secrets:`) is preserved verbatim so a rewrite can't drop it.
_MANAGED_GLOBALS = {
"gateway", "repo", "data_dir", "repos_dir", "agents", "role",
@@ -640,10 +640,10 @@ _MANAGED_GLOBALS = {
}
def write_deployment_file(config: CastleConfig, kind: str, name: str) -> None:
def write_deployment_file(config: WildpcConfig, kind: str, name: str) -> None:
"""Write (or remove) a single deployment file — globals and other resources are
left untouched. This is the PATCH primitive: a deployment edit persists only
that deployment, never rewriting castle.yaml globals."""
that deployment, never rewriting wildpc.yaml globals."""
directory = config.root / "deployments" / _KIND_STORE[kind]
path = directory / f"{name}.yaml"
spec = config.store_for(kind).get(name)
@@ -655,7 +655,7 @@ def write_deployment_file(config: CastleConfig, kind: str, name: str) -> None:
yaml.dump(_spec_to_yaml_dict(spec), f, default_flow_style=False, sort_keys=False)
def write_program_file(config: CastleConfig, name: str) -> None:
def write_program_file(config: WildpcConfig, name: str) -> None:
"""Write (or remove) a single program file — nothing else is touched."""
directory = config.root / "programs"
path = directory / f"{name}.yaml"
@@ -670,8 +670,8 @@ def write_program_file(config: CastleConfig, name: str) -> None:
)
def save_config(config: CastleConfig) -> None:
"""Save castle config: global castle.yaml + programs/ and deployments/ dirs."""
def save_config(config: WildpcConfig) -> None:
"""Save wildpc config: global wildpc.yaml + programs/ and deployments/ dirs."""
gateway_data: dict = {"port": config.gateway.port}
if config.gateway.tls:
gateway_data["tls"] = config.gateway.tls
@@ -679,7 +679,7 @@ def save_config(config: CastleConfig) -> None:
gateway_data["domain"] = config.gateway.domain
if config.gateway.acme_email:
gateway_data["acme_email"] = config.gateway.acme_email
# Only persist the provider when non-default, to keep castle.yaml minimal.
# Only persist the provider when non-default, to keep wildpc.yaml minimal.
if (
config.gateway.acme_dns_provider
and config.gateway.acme_dns_provider != "cloudflare"
@@ -694,7 +694,7 @@ def save_config(config: CastleConfig) -> None:
data: dict = {"gateway": gateway_data}
if config.repo:
data["repo"] = str(config.repo)
# Persist the configurable roots only when non-default, keeping castle.yaml minimal.
# Persist the configurable roots only when non-default, keeping wildpc.yaml minimal.
# These MUST round-trip: save_config rewrites the file from scratch, so a root that
# isn't re-emitted here would be silently dropped on the next apply.
if config.data_dir != _DEFAULT_DATA_DIR:
@@ -713,17 +713,17 @@ def save_config(config: CastleConfig) -> None:
# from the existing file.
if config.role and config.role != "follower":
data["role"] = config.role
# Preserve any top-level castle.yaml keys this writer doesn't model (e.g.
# Preserve any top-level wildpc.yaml keys this writer doesn't model (e.g.
# `secrets:`) — a full rewrite must never silently drop an unmanaged global.
try:
existing = yaml.safe_load((config.root / "castle.yaml").read_text()) or {}
existing = yaml.safe_load((config.root / "wildpc.yaml").read_text()) or {}
for k, v in existing.items():
if k not in _MANAGED_GLOBALS and k not in data:
data[k] = v
except Exception:
pass
config_path = config.root / "castle.yaml"
config_path = config.root / "wildpc.yaml"
with open(config_path, "w") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
@@ -740,10 +740,10 @@ def save_config(config: CastleConfig) -> None:
)
def ensure_dirs(config: CastleConfig) -> None:
"""Ensure castle directories exist. Takes the config so the data dir comes from the
def ensure_dirs(config: WildpcConfig) -> None:
"""Ensure wildpc directories exist. Takes the config so the data dir comes from the
one source of truth (config.data_dir), not a process-resolved global."""
CASTLE_HOME.mkdir(parents=True, exist_ok=True)
WILDPC_HOME.mkdir(parents=True, exist_ok=True)
CODE_DIR.mkdir(parents=True, exist_ok=True)
SPECS_DIR.mkdir(parents=True, exist_ok=True)
CONTENT_DIR.mkdir(parents=True, exist_ok=True)
@@ -753,9 +753,9 @@ def ensure_dirs(config: CastleConfig) -> None:
try:
data_dir.mkdir(parents=True, exist_ok=True)
except OSError as e:
raise CastleDirError(
raise WildpcDirError(
f"Cannot create data dir {data_dir}: {e.strerror or e}. "
f"Set data_dir: in {CASTLE_HOME / 'castle.yaml'} (or export CASTLE_DATA_DIR) "
f"Set data_dir: in {WILDPC_HOME / 'wildpc.yaml'} (or export WILDPC_DATA_DIR) "
f"to a writable path, or create it: "
f"sudo mkdir -p {data_dir} && sudo chown $(id -un) {data_dir}"
) from e

View File

@@ -1,7 +1,7 @@
"""Deploy logic — bridge castle.yaml spec to runtime (~/.castle/).
"""Deploy logic — bridge wildpc.yaml spec to runtime (~/.wildpc/).
This module contains the core deploy logic shared by the CLI and API.
It reads castle.yaml, resolves services/jobs into Deployments,
It reads wildpc.yaml, resolves services/jobs into Deployments,
writes the registry, generates systemd units and the Caddyfile, and
copies frontend build outputs.
"""
@@ -15,24 +15,24 @@ from collections.abc import Sequence
from dataclasses import dataclass, field
from pathlib import Path
from castle_core.config import (
from wildpc_core.config import (
SPECS_DIR,
CastleConfig,
WildpcConfig,
ensure_dirs,
load_config,
resolve_env_split,
resolve_placeholders,
)
from castle_core.generators.caddyfile import (
from wildpc_core.generators.caddyfile import (
_DNS_TOKEN_ENV,
generate_caddyfile_from_registry,
)
from castle_core.generators.dns import reconcile_public_dns
from castle_core.generators.tunnel import (
from wildpc_core.generators.dns import reconcile_public_dns
from wildpc_core.generators.tunnel import (
generate_tunnel_config,
public_hostnames,
)
from castle_core.generators.systemd import (
from wildpc_core.generators.systemd import (
SECRET_ENV_DIR,
generate_timer,
generate_unit_from_deployed,
@@ -41,7 +41,7 @@ from castle_core.generators.systemd import (
unit_env_file,
unit_name,
)
from castle_core.manifest import (
from wildpc_core.manifest import (
CaddyDeployment,
DeploymentBase,
DeploymentSpec,
@@ -50,7 +50,7 @@ from castle_core.manifest import (
TlsMaterial,
kind_for,
)
from castle_core.registry import (
from wildpc_core.registry import (
REGISTRY_PATH,
Deployment,
NodeConfig,
@@ -58,7 +58,7 @@ from castle_core.registry import (
load_registry,
save_registry,
)
from castle_core.toolchains import ToolchainError, resolve_node_bin
from wildpc_core.toolchains import ToolchainError, resolve_node_bin
SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
@@ -74,7 +74,7 @@ class DeployResult:
@dataclass
class ApplyResult:
"""Result of a converge (`castle apply`): what actually changed.
"""Result of a converge (`wildpc apply`): what actually changed.
`deploy` renders config artifacts; `apply` renders *and then* reconciles the
running system to match, so the interesting output is the diff it enacted.
@@ -115,11 +115,11 @@ class ApplyResult:
def deploy(target_name: str | None = None, root: Path | None = None) -> DeployResult:
"""Deploy from castle.yaml to ~/.castle/.
"""Deploy from wildpc.yaml to ~/.wildpc/.
Args:
target_name: Deploy a single service/job by name, or None for all.
root: Config root path. If None, uses find_castle_root().
root: Config root path. If None, uses find_wildpc_root().
Returns:
DeployResult with deployed count, messages, and the registry.
@@ -195,10 +195,10 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe
return result
def _node_config(config: CastleConfig) -> NodeConfig:
def _node_config(config: WildpcConfig) -> NodeConfig:
"""The registry NodeConfig derived from a config's gateway settings."""
return NodeConfig(
castle_root=str(config.root),
wildpc_root=str(config.root),
gateway_port=config.gateway.port,
gateway_tls=config.gateway.tls,
gateway_domain=config.gateway.domain,
@@ -224,7 +224,7 @@ def _unit_bytes(name: str, kind: str) -> str | None:
return path.read_text() if path.exists() else None
def _desired_registry(config: CastleConfig, target_name: str | None) -> NodeRegistry:
def _desired_registry(config: WildpcConfig, target_name: str | None) -> NodeRegistry:
"""The registry ``deploy()`` would write for this (optionally scoped) run.
Mirrors deploy()'s registry build: a scoped run merges the updated target over
@@ -247,7 +247,7 @@ def _desired_registry(config: CastleConfig, target_name: str | None) -> NodeRegi
return registry
def _gateway_would_change(config: CastleConfig, target_name: str | None) -> bool:
def _gateway_would_change(config: WildpcConfig, target_name: str | None) -> bool:
"""Whether applying would rewrite the gateway's routing artifacts — the
Caddyfile or the cloudflared ingress vs. what's on disk.
@@ -284,8 +284,8 @@ def apply(
"""
import asyncio
from castle_core.config import active_backend_name
from castle_core.lifecycle import activate, deactivate, is_active
from wildpc_core.config import active_backend_name
from wildpc_core.lifecycle import activate, deactivate, is_active
config = load_config(root)
# Each item is (kind, name, spec); target_name matches every kind of that name.
@@ -315,7 +315,7 @@ def apply(
for n, secrets in blocked:
for s in secrets:
result.messages.append(
f" {n}: ${{secret:{s}}} → not found. Fix: castle secret set {s}"
f" {n}: ${{secret:{s}}} → not found. Fix: wildpc secret set {s}"
)
return result
@@ -378,8 +378,8 @@ def apply(
# materialization to the deployments being applied so a scoped apply doesn't
# rewrite an unrelated service's cert without reloading it. No reload here — the
# activation loop below starts/restarts as needed; rotation-driven reloads are
# the `castle tls reconcile` / cert_obtained path.
from castle_core.tls import materialize_all, wait_for_wildcard
# the `wildpc tls reconcile` / cert_obtained path.
from wildpc_core.tls import materialize_all, wait_for_wildcard
wait_for_wildcard(config, names, result.messages)
materialize_all(config, result.messages, only=names)
@@ -404,7 +404,7 @@ def apply(
def _stack_preflight(
config: CastleConfig,
config: WildpcConfig,
items: Sequence[tuple[str, str, object]],
messages: list[str],
) -> None:
@@ -412,8 +412,8 @@ def _stack_preflight(
*where it runs* the moment drift actually bites: a service whose `uv`/`pnpm`
isn't on its runtime PATH won't build or start. Mirrors `_acme_preflight`: an
advisory message, no writes, no gate. The exact fix comes from the tool's hint."""
from castle_core.relations import _tool_available
from castle_core.stacks import tools_for
from wildpc_core.relations import _tool_available
from wildpc_core.stacks import tools_for
for _k, n, spec in items:
if not getattr(spec, "enabled", True):
@@ -441,7 +441,7 @@ def _unresolved_secrets(
the old path substituted the marker and started the service anyway. Checked
against the active backend so it's correct whichever backend is configured.
"""
from castle_core.config import active_secret_backend, secret_refs
from wildpc_core.config import active_secret_backend, secret_refs
backend = active_secret_backend()
resolved: dict[str, bool] = {} # cache: one backend read per distinct secret
@@ -480,7 +480,7 @@ def _record(result: ApplyResult, name: str, action: str) -> None:
def _render_unit_preview(
config: CastleConfig, name: str, dep: Deployment, kind: str
config: WildpcConfig, name: str, dep: Deployment, kind: str
) -> str | None:
"""The unit bytes `deploy` would write for the deployment we'd restart (the
.timer for a job, the .service for a service), for --plan restart detection.
@@ -491,16 +491,16 @@ def _render_unit_preview(
return files.get(timer_name(name) if kind == "job" else unit_name(name, kind))
# Gateway service name in the registry → its systemd unit (castle-castle-gateway).
_GATEWAY_NAME = "castle-gateway"
# Gateway service name in the registry → its systemd unit (wildpc-wildpc-gateway).
_GATEWAY_NAME = "wildpc-gateway"
def _acme_preflight(config: CastleConfig, messages: list[str]) -> None:
def _acme_preflight(config: WildpcConfig, messages: list[str]) -> None:
"""Warn (never fail, never write) if acme-mode prerequisites are missing.
acme mode needs a `gateway.domain`, the DNS-provider token mapped into the
castle-gateway service env, and the matching secret on disk all operator
steps (castle never rewrites the user-authored gateway service YAML)."""
wildpc-gateway service env, and the matching secret on disk all operator
steps (wildpc never rewrites the user-authored gateway service YAML)."""
gw = config.gateway
if not gw.domain:
messages.append(
@@ -519,7 +519,7 @@ def _acme_preflight(config: CastleConfig, messages: list[str]) -> None:
f"Add to services/{_GATEWAY_NAME}.yaml → defaults.env: "
f"{token_env}: ${{secret:{token_env}}}"
)
from castle_core.config import read_secret
from wildpc_core.config import read_secret
if not read_secret(token_env):
messages.append(
@@ -528,8 +528,8 @@ def _acme_preflight(config: CastleConfig, messages: list[str]) -> None:
)
# Tunnel service name in the registry → its systemd unit (castle-castle-tunnel).
_TUNNEL_NAME = "castle-tunnel"
# Tunnel service name in the registry → its systemd unit (wildpc-wildpc-tunnel).
_TUNNEL_NAME = "wildpc-tunnel"
def _write_tunnel_config(registry: NodeRegistry, messages: list[str]) -> None:
@@ -548,7 +548,7 @@ def _write_tunnel_config(registry: NodeRegistry, messages: list[str]) -> None:
if config_path.exists():
config_path.unlink()
messages.append("No public services — removed cloudflared config.")
# Still reconcile so any CNAMEs castle created earlier are cleaned up.
# Still reconcile so any CNAMEs wildpc created earlier are cleaned up.
reconcile_public_dns(node.tunnel_id, [], messages)
return
@@ -575,17 +575,17 @@ def _write_tunnel_config(registry: NodeRegistry, messages: list[str]) -> None:
messages.append("Tunnel reloaded.")
else:
messages.append(
"Tunnel not running — enable it with 'castle service enable tunnel'."
"Tunnel not running — enable it with 'wildpc service enable tunnel'."
)
def _gateway_env(config: CastleConfig) -> dict[str, str]:
def _gateway_env(config: WildpcConfig) -> dict[str, str]:
"""The process env for validating/handling the gateway's Caddyfile — the
current environment plus the castle-gateway service's own resolved env.
current environment plus the wildpc-gateway service's own resolved env.
Under acme the Caddyfile references ``{env.CLOUDFLARE_API_TOKEN}`` (or another
DNS-provider token). `caddy validate` provisions the acme module and rejects an
empty token, so validating in castle-api's bare environment always fails and the
empty token, so validating in wildpc-api's bare environment always fails and the
reload is skipped. Injecting the gateway service's env (secrets resolved) gives
validate the same token the running service starts with."""
svc = config.services.get(_GATEWAY_NAME)
@@ -595,7 +595,7 @@ def _gateway_env(config: CastleConfig) -> dict[str, str]:
return {**os.environ, **resolved}
def _reload_gateway(config: CastleConfig, messages: list[str]) -> None:
def _reload_gateway(config: WildpcConfig, messages: list[str]) -> None:
"""Reload Caddy if the gateway is running, so new routes take effect."""
gw_unit = unit_name(_GATEWAY_NAME)
# Validate the generated Caddyfile before reloading. An invalid config (most
@@ -630,7 +630,7 @@ def _reload_gateway(config: CastleConfig, messages: list[str]) -> None:
)
if active.stdout.strip() != "active":
messages.append(
"Gateway not running — skipped reload (start it with 'castle gateway start')."
"Gateway not running — skipped reload (start it with 'wildpc gateway start')."
)
return
result = subprocess.run(
@@ -648,7 +648,7 @@ def _reload_gateway(config: CastleConfig, messages: list[str]) -> None:
def _public_url(
config: CastleConfig, name: str, exposed: bool, port: int | None
config: WildpcConfig, name: str, exposed: bool, port: int | None
) -> str | None:
"""The service's publicly-reachable base URL — the ``${public_url}`` placeholder.
@@ -667,7 +667,7 @@ def _public_url(
return None
def _target_url(config: CastleConfig, target_name: str) -> str | None:
def _target_url(config: WildpcConfig, target_name: str) -> str | None:
"""The base URL another deployment is reachable at — how a ``{kind: deployment,
bind: VAR}`` requirement projects its target into the consumer's env. A name may
span kinds; the HTTP-exposed one is what has a URL, so prefer it."""
@@ -688,7 +688,7 @@ def _target_url(config: CastleConfig, target_name: str) -> str | None:
return _public_url(config, target_name, getattr(dep, "http_exposed", False), tport)
def _requires_env(config: CastleConfig, dep: DeploymentSpec) -> dict[str, str]:
def _requires_env(config: WildpcConfig, dep: DeploymentSpec) -> dict[str, str]:
"""Env generated FROM a deployment's ``requires`` — a ``{ref, bind: VAR}``
requirement sets ``VAR`` to the target deployment's URL. Env is derived from the
dependency, never scraped back into one (see docs/relationships.md)."""
@@ -701,7 +701,7 @@ def _requires_env(config: CastleConfig, dep: DeploymentSpec) -> dict[str, str]:
return out
def _supabase_app_schemas(config: CastleConfig) -> str:
def _supabase_app_schemas(config: WildpcConfig) -> str:
"""The ``${supabase_app_schemas}`` placeholder: each registered supabase app's
own schema, comma-prefixed and joined (or '' when there are none).
@@ -710,9 +710,9 @@ def _supabase_app_schemas(config: CastleConfig) -> str:
``public,storage,graphql_public${supabase_app_schemas}``. Comma-prefixing each
entry keeps the base list valid when zero apps are registered (no trailing
comma). Adding/removing a supabase app thus changes this list the substrate
needs a restart (recreate) after `castle deploy` to pick it up.
needs a restart (recreate) after `wildpc deploy` to pick it up.
"""
from castle_core.stacks import app_schema
from wildpc_core.stacks import app_schema
schemas = sorted(
app_schema(pn) for pn, ps in config.programs.items() if ps.stack == "supabase"
@@ -774,7 +774,7 @@ def _write_secret_env_file(
return path
def _resolve_description(config: CastleConfig, spec: DeploymentBase) -> str | None:
def _resolve_description(config: WildpcConfig, spec: DeploymentBase) -> str | None:
"""Get description, falling through to program if referenced."""
if spec.description:
return spec.description
@@ -793,7 +793,7 @@ def _registry_requires(dep: DeploymentSpec) -> list[dict]:
def _build_deployed(
config: CastleConfig, name: str, dep: DeploymentSpec, messages: list[str]
config: WildpcConfig, name: str, dep: DeploymentSpec, messages: list[str]
) -> Deployment:
"""Build a runtime Deployment from a DeploymentSpec, dispatched by its manager."""
description = _resolve_description(config, dep)
@@ -851,7 +851,7 @@ def _build_deployed(
run = dep.run
# ${data_dir} is keyed by the program the deployment runs, not the deployment
# name (e.g. job `protonmail-sync` runs program `protonmail` →
# /data/castle/protonmail). Falls back to the deployment name.
# /data/wildpc/protonmail). Falls back to the deployment name.
config_key = dep.program or name
managed = True
@@ -871,7 +871,7 @@ def _build_deployed(
# Env is exactly what's in defaults.env — no hidden convention injection.
# ${port}/${data_dir}/${name}/${public_url} map the program's own env var
# names to castle's computed values. Secret-bearing vars split out to a
# names to wildpc's computed values. Secret-bearing vars split out to a
# mode-0600 file (never in the unit or argv).
raw_env = dict(dep.defaults.env) if (dep.defaults and dep.defaults.env) else {}
# Env generated from `requires` ({kind: deployment, bind: VAR} → target URL).
@@ -887,12 +887,12 @@ def _build_deployed(
public_url,
_supabase_app_schemas(config),
)
# ${tls_*}: paths to castle-materialized cert files for a TLS-material TCP
# ${tls_*}: paths to wildpc-materialized cert files for a TLS-material TCP
# service. The deployment maps them into its own config (mount ${tls_dir} for a
# container, or reference ${tls_cert}/${tls_key} directly for a native service).
tls = dep.expose.tcp.tls if (dep.expose and dep.expose.tcp) else None
if tls and tls.material != TlsMaterial.OFF:
from castle_core.tls import tls_dir_for
from wildpc_core.tls import tls_dir_for
tls_dir = tls_dir_for(config.data_dir, config_key)
ctx.update(
@@ -981,7 +981,7 @@ def _python_tool_needs_install(program: str) -> bool:
return False
def _program_source_dir(config: CastleConfig, program: str | None) -> Path | None:
def _program_source_dir(config: WildpcConfig, program: str | None) -> Path | None:
"""The absolute source dir of a referenced program, if any.
`load_config` has already resolved `source` to an absolute path (repo: and
@@ -994,7 +994,7 @@ def _program_source_dir(config: CastleConfig, program: str | None) -> Path | Non
def _ensure_python_tool(
config: CastleConfig, program: str | None, messages: list[str]
config: WildpcConfig, program: str | None, messages: list[str]
) -> None:
"""Ensure a Python program's editable install is current.
@@ -1029,7 +1029,7 @@ def _ensure_python_tool(
def _subst(value: str, placeholders: dict[str, str] | None) -> str:
"""Expand ``${key}`` in a run-spec string field from castle's computed values
"""Expand ``${key}`` in a run-spec string field from wildpc's computed values
(``${uid}``/``${gid}``/``${data_dir}``/``${tls_dir}``/), via the one shared
``${...}`` resolver (:func:`resolve_placeholders`). Unknown refs pass through
unchanged (secrets never belong in argv they go via --env-file); write
@@ -1085,12 +1085,12 @@ def _build_run_cmd(
case "container":
runtime = shutil.which("docker") or shutil.which("podman") or "docker"
# Container name derives from the SERVICE name (matches the systemd unit),
# not the image name — so `castle-<service>` is stable and collision-free.
cmd = [runtime, "run", "--rm", f"--name=castle-{name}"]
# not the image name — so `wildpc-<service>` is stable and collision-free.
cmd = [runtime, "run", "--rm", f"--name=wildpc-{name}"]
if run.user: # type: ignore[union-attr]
# Run as the invoking user (uid uniformity → bind-mounted
# certs/data/secrets readable with no chown). ${uid}/${gid} expand
# to the castle process's own ids.
# to the wildpc process's own ids.
cmd.extend(["--user", _subst(run.user, placeholders)]) # type: ignore[union-attr]
for tp in run.tmpfs: # type: ignore[union-attr]
cmd.extend(["--tmpfs", _subst(tp, placeholders)])
@@ -1147,7 +1147,7 @@ def _compose_base(name: str, run: object, source_dir: Path | None) -> list[str]:
namespaced and collision-free.
"""
runtime = shutil.which("docker") or shutil.which("podman") or "docker"
project = run.project_name or f"castle-{name}" # type: ignore[union-attr]
project = run.project_name or f"wildpc-{name}" # type: ignore[union-attr]
compose_file = Path(run.file) # type: ignore[union-attr]
if not compose_file.is_absolute() and source_dir is not None:
compose_file = source_dir / compose_file
@@ -1204,9 +1204,9 @@ def _teardown_unit(unit_file: str, messages: list[str]) -> None:
def _prune_orphans(registry: NodeRegistry, messages: list[str]) -> None:
"""Remove castle-* units no longer backed by a managed registry entry.
"""Remove wildpc-* units no longer backed by a managed registry entry.
The `castle-` prefix is the ownership namespace: any castle-*.service/.timer on
The `wildpc-` prefix is the ownership namespace: any wildpc-*.service/.timer on
disk that isn't in the desired set is an orphan (a removed/unmanaged/unscheduled
component) and is torn down. Only call on a FULL deploy the desired set must
reflect the whole registry, not a single --target.
@@ -1214,14 +1214,14 @@ def _prune_orphans(registry: NodeRegistry, messages: list[str]) -> None:
desired = _desired_unit_files(registry)
if not SYSTEMD_USER_DIR.is_dir():
return
for pattern in ("castle-*.service", "castle-*.timer"):
for pattern in ("wildpc-*.service", "wildpc-*.timer"):
for path in sorted(SYSTEMD_USER_DIR.glob(pattern)):
if path.name not in desired:
_teardown_unit(path.name, messages)
def _render_unit_files(
config: CastleConfig, name: str, deployed: Deployment
config: WildpcConfig, name: str, deployed: Deployment
) -> dict[str, str]:
"""The exact unit files `deploy` would write for a deployment: {filename: content}.
@@ -1250,7 +1250,7 @@ def _render_unit_files(
return files
def _generate_systemd_units(config: CastleConfig, registry: NodeRegistry) -> None:
def _generate_systemd_units(config: WildpcConfig, registry: NodeRegistry) -> None:
"""Generate systemd units from the registry."""
SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)

View File

@@ -1,9 +1,9 @@
"""Castle infrastructure generators."""
"""Wild PC infrastructure generators."""
from castle_core.generators.caddyfile import (
from wildpc_core.generators.caddyfile import (
generate_caddyfile_from_registry,
)
from castle_core.generators.systemd import (
from wildpc_core.generators.systemd import (
cron_to_interval_sec,
cron_to_oncalendar,
generate_timer,

View File

@@ -18,15 +18,15 @@ import os
from dataclasses import dataclass
from pathlib import Path
from castle_core.config import SPECS_DIR, CastleConfig
from castle_core.manifest import CaddyDeployment, SystemdDeployment
from castle_core.registry import NodeRegistry
from wildpc_core.config import SPECS_DIR, WildpcConfig
from wildpc_core.manifest import CaddyDeployment, SystemdDeployment
from wildpc_core.registry import NodeRegistry
# DNS-01 provider → the env var the Caddyfile reads its API token from. The token
# reaches Caddy via the gateway service's defaults.env (a mode-0600 EnvironmentFile).
_DNS_TOKEN_ENV = {"cloudflare": "CLOUDFLARE_API_TOKEN"}
# Let's Encrypt staging directory — opt in via CASTLE_ACME_STAGING=1 to avoid the
# Let's Encrypt staging directory — opt in via WILDPC_ACME_STAGING=1 to avoid the
# production rate limits while testing, then cut over to prod (unset the env var).
_ACME_STAGING_CA = "https://acme-staging-v02.api.letsencrypt.org/directory"
@@ -71,12 +71,12 @@ def service_proxy_targets(name: str, dep: SystemdDeployment) -> ProxyTargets:
def _local_routes(
config: CastleConfig | None, registry: NodeRegistry
config: WildpcConfig | None, registry: NodeRegistry
) -> list[tuple[str, str, str, bool, str | None, bool]]:
"""Each local deployment's route as ``(name, kind, target, public, public_host, spa)``.
``kind`` is ``static`` (a caddy deployment file-serve a built dir) or
``proxy`` (a proxied systemd process). Prefers ``castle.yaml``
``proxy`` (a proxied systemd process). Prefers ``wildpc.yaml``
(``config.deployments``) so a regenerated Caddyfile reflects the current spec;
falls back to the deployed registry snapshot when config isn't available.
"""
@@ -86,7 +86,7 @@ def _local_routes(
# jobs/tools/references never do.
for kind, name, dep in config.all_deployments():
# A disabled deployment is defined but not running — no route (else it
# would 502). `castle apply` converges it off.
# would 502). `wildpc apply` converges it off.
if not dep.enabled:
continue
if kind == "static" and isinstance(dep, CaddyDeployment):
@@ -111,7 +111,7 @@ def _local_routes(
return out
def _program_source(config: CastleConfig | None, program: str | None):
def _program_source(config: WildpcConfig | None, program: str | None):
"""Absolute source dir of a referenced program (already resolved), or None."""
if config is None or not program:
return None
@@ -123,7 +123,7 @@ def _program_source(config: CastleConfig | None, program: str | None):
def compute_routes(
registry: NodeRegistry,
config: CastleConfig | None = None,
config: WildpcConfig | None = None,
remote_registries: dict[str, NodeRegistry] | None = None,
) -> list[GatewayRoute]:
"""Build the ordered list of gateway routes. Every route is a host route whose
@@ -135,7 +135,7 @@ def compute_routes(
so a consumed cross-node service is reachable at ``<ref>.<domain>``."""
if config is None:
try:
from castle_core.config import load_config
from wildpc_core.config import load_config
config = load_config()
except Exception:
@@ -158,7 +158,7 @@ def compute_routes(
def _remote_routes(
config: CastleConfig | None,
config: WildpcConfig | None,
registry: NodeRegistry,
remote_registries: dict[str, NodeRegistry],
) -> list[GatewayRoute]:
@@ -278,11 +278,11 @@ def _public_site_block(host: str, kind: str, target: str, spa: bool = True) -> l
return [f"{host} {{", *body, "}", ""]
# Castle's own control plane: the dashboard frontend and the API it calls. These
# Wild PC's own control plane: the dashboard frontend and the API it calls. These
# names are the subdomains they're published at in acme mode, and the pair served
# on the :<port> site in off mode (no domain → no subdomains).
_DASHBOARD = "castle"
_API = "castle-api"
_DASHBOARD = "wildpc"
_API = "wildpc-api"
def generate_caddyfile_from_registry(
@@ -299,8 +299,8 @@ def generate_caddyfile_from_registry(
The `:<port>` site just redirects to the dashboard subdomain (the "browse to
the box by IP" entry).
- **off / no domain** no subdomains available, so the `:<port>` site serves
castle's control plane only: the dashboard at `/` + `reverse_proxy /api/*` →
castle-api (the one surviving path, for the dashboard's own backend). Other
wildpc's control plane only: the dashboard at `/` + `reverse_proxy /api/*` →
wildpc-api (the one surviving path, for the dashboard's own backend). Other
services are reachable at their `host:port` directly.
"""
routes = compute_routes(registry, None, remote_registries)
@@ -319,7 +319,7 @@ def generate_caddyfile_from_registry(
if node.acme_email:
lines.append(f" email {node.acme_email}")
lines.append(f" acme_dns {provider} {{env.{token_env}}}")
if os.environ.get("CASTLE_ACME_STAGING") == "1":
if os.environ.get("WILDPC_ACME_STAGING") == "1":
lines.append(f" acme_ca {_ACME_STAGING_CA}")
# On issuance/renewal, refresh certs materialized onto raw-TCP services and
# reload them (idempotent — a no-op when nothing rotated). Requires the
@@ -330,7 +330,7 @@ def generate_caddyfile_from_registry(
if getattr(node, "cert_hook", False):
lines += [
" events {",
" on cert_obtained exec castle tls reconcile",
" on cert_obtained exec wildpc tls reconcile",
" }",
]
lines += ["}", ""]

View File

@@ -1,6 +1,6 @@
"""Reconcile public DNS (Cloudflare CNAMEs) for tunnel-exposed services.
Castle owns the CNAMEs across every zone the token can see that point at its
Wild PC owns the CNAMEs across every zone the token can see that point at its
Cloudflare tunnel: on deploy it creates one per public host (each routed to the
accessible zone whose name is its longest suffix, so apex and multi-zone hosts both
work) and deletes any that point at this tunnel but no longer correspond to a
@@ -11,7 +11,7 @@ hand-managed A/CNAME in the same zone is safe.
Needs a Cloudflare API token with **DNS:Edit** on every target zone (Cloudflare's
"Edit zone DNS" template that single permission both lists the accessible zones
and edits records; no separate Zone:Read is needed), stored at
`~/.castle/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN`. Absent this is a no-op and the
`~/.wildpc/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN`. Absent this is a no-op and the
caller falls back to surfacing the manual `cloudflared tunnel route dns` hints.
"""
@@ -31,7 +31,7 @@ PUBLIC_DNS_TOKEN = "CLOUDFLARE_PUBLIC_DNS_TOKEN"
def public_dns_token() -> str | None:
"""The public-zone DNS token from the active secret backend, or None."""
from castle_core.config import read_secret
from wildpc_core.config import read_secret
return read_secret(PUBLIC_DNS_TOKEN) or None
@@ -69,8 +69,8 @@ def reconcile_public_dns(
Each desired host is routed to the accessible zone whose name is its longest
suffix (so apex hosts and hosts in different zones are handled), then per zone
castle creates missing CNAMEs (proxied the tunnel; Cloudflare flattens apex
CNAMEs) and deletes castle-managed ones (content == `<tunnel_id>.cfargotunnel.com`)
wildpc creates missing CNAMEs (proxied the tunnel; Cloudflare flattens apex
CNAMEs) and deletes wildpc-managed ones (content == `<tunnel_id>.cfargotunnel.com`)
no longer desired. Never touches records pointing elsewhere. Scanning every
visible zone also cleans up stale CNAMEs after a host moves zones or all public
services are removed.

View File

@@ -5,12 +5,12 @@ from __future__ import annotations
import shutil
from pathlib import Path
from castle_core.config import SECRETS_DIR, USER_TOOL_PATH_DIRS
from castle_core.manifest import RestartPolicy, SystemdSpec
from castle_core.registry import Deployment
from wildpc_core.config import SECRETS_DIR, USER_TOOL_PATH_DIRS
from wildpc_core.manifest import RestartPolicy, SystemdSpec
from wildpc_core.registry import Deployment
SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
UNIT_PREFIX = "castle-"
UNIT_PREFIX = "wildpc-"
# Generated mode-0600 env files holding a deployment's resolved secrets, kept out
# of the unit file and the process argv (loaded via EnvironmentFile= / --env-file).
@@ -18,7 +18,7 @@ SECRET_ENV_DIR = SECRETS_DIR / "env"
def runtime_path(path_prepend: list[str] | tuple[str, ...] = ()) -> str:
"""The PATH a castle service runs with: resolved toolchain dirs (e.g. a pinned
"""The PATH a wildpc service runs with: resolved toolchain dirs (e.g. a pinned
node bin) + the user tool dirs that exist + system bins. This is the single
definition of a service's runtime PATH — the unit generator writes it into
``Environment=PATH`` and the dependency checker (``relations``) probes tools
@@ -32,8 +32,8 @@ def runtime_path(path_prepend: list[str] | tuple[str, ...] = ()) -> str:
def unit_basename(name: str, kind: str = "service") -> str:
"""The systemd unit stem for a deployment. Jobs carry a ``-job`` marker so a
service and a job can share a name (`castle-<name>.service` vs
`castle-<name>-job.{service,timer}`); everything else is `castle-<name>`."""
service and a job can share a name (`wildpc-<name>.service` vs
`wildpc-<name>-job.{service,timer}`); everything else is `wildpc-<name>`."""
return f"{UNIT_PREFIX}{name}-job" if kind == "job" else f"{UNIT_PREFIX}{name}"
@@ -132,7 +132,7 @@ def generate_unit_from_deployed(
env_lines = ""
for key, value in deployed.env.items():
env_lines += f"Environment={key}={value}\n"
# Castle supplies a sensible default PATH (tool dirs + system bins). It is an
# Wild PC supplies a sensible default PATH (tool dirs + system bins). It is an
# escape hatch, not a mandate: if the service pins its own PATH in defaults.env
# (e.g. to add a versioned nvm node the tool dirs intentionally omit), respect
# it rather than clobbering it with a trailing Environment=PATH line that would
@@ -151,7 +151,7 @@ def generate_unit_from_deployed(
if deployed.schedule:
unit = f"""[Unit]
Description=Castle: {description}
Description=Wild PC: {description}
After={after}
[Service]
@@ -162,7 +162,7 @@ ExecStart={exec_start}
restart = (sd.restart if sd else RestartPolicy.ON_FAILURE).value
restart_sec = sd.restart_sec if sd else 5
unit = f"""[Unit]
Description=Castle: {description}
Description=Wild PC: {description}
After={after}
[Service]
@@ -224,7 +224,7 @@ def generate_timer(
timer_lines = "OnBootSec=60\nOnUnitActiveSec=300\n"
return f"""[Unit]
Description=Castle timer: {description}
Description=Wild PC timer: {description}
[Timer]
{timer_lines}Persistent=false

View File

@@ -20,8 +20,8 @@ from pathlib import Path
import yaml
from castle_core.config import SECRETS_DIR
from castle_core.registry import Deployment, NodeRegistry
from wildpc_core.config import SECRETS_DIR
from wildpc_core.registry import Deployment, NodeRegistry
# Cloudflared tunnel credentials (from `cloudflared tunnel create`) live here as a
# secret, one JSON per tunnel id. The generated config references this path.

View File

@@ -1,13 +1,13 @@
"""Git working-copy status and sync for programs whose source is a git repo.
Programs that declare a ``repo:`` URL are cloned once (``castle program clone``);
this module lets a running castle *see how far behind* a working copy is and pull
Programs that declare a ``repo:`` URL are cloned once (``wildpc program clone``);
this module lets a running wildpc *see how far behind* a working copy is and pull
later updates. It is intentionally pull-only it touches files on disk and never
builds, applies, or restarts anything. Making the running artifact reflect the new
code (rebuild a frontend, restart a service) stays an explicit, separate step via
``castle apply`` / ``castle restart``.
``wildpc apply`` / ``wildpc restart``.
Plain ``git`` via subprocess (matching ``castle program clone``); no GitPython.
Plain ``git`` via subprocess (matching ``wildpc program clone``); no GitPython.
"""
from __future__ import annotations

View File

@@ -18,8 +18,8 @@ import sys
import time
from pathlib import Path
from castle_core.config import CastleConfig
from castle_core.generators.systemd import (
from wildpc_core.config import WildpcConfig
from wildpc_core.generators.systemd import (
SYSTEMD_USER_DIR,
generate_timer,
generate_unit_from_deployed,
@@ -28,9 +28,9 @@ from castle_core.generators.systemd import (
unit_env_file,
unit_name,
)
from castle_core.manifest import CaddyDeployment
from castle_core.registry import REGISTRY_PATH, load_registry
from castle_core.stacks import ActionResult, run_action
from wildpc_core.manifest import CaddyDeployment
from wildpc_core.registry import REGISTRY_PATH, load_registry
from wildpc_core.stacks import ActionResult, run_action
def _systemctl_active(unit: str) -> bool:
@@ -73,7 +73,7 @@ def _uv_tool_packages() -> set[str]:
def _own_venv_bins() -> set[str]:
"""Bin dirs of the *running* interpreter's virtualenv.
Excluded from install detection: when the api server (or a dev `castle`) runs
Excluded from install detection: when the api server (or a dev `wildpc`) runs
inside a project venv via `uv run`, a tool merely *colocated* there e.g. an
editable a dev accidentally `uv pip install`ed while that venv was active must
not read as "installed on PATH" for the user. We only strip *our own* venv, not
@@ -113,13 +113,13 @@ def tool_installed(name: str) -> bool:
return _on_path(name)
def _svc_manager(name: str, kind: str, config: CastleConfig) -> str | None:
def _svc_manager(name: str, kind: str, config: WildpcConfig) -> str | None:
"""The manager for a deployment (name, kind), or None if not in config."""
dep = config.deployment(kind, name)
return dep.manager if dep is not None else None
def _static_built(name: str, config: CastleConfig) -> bool:
def _static_built(name: str, config: WildpcConfig) -> bool:
"""Whether a static (caddy) deployment's served dir exists (assets are built)."""
dep = config.statics.get(name)
if not isinstance(dep, CaddyDeployment):
@@ -133,7 +133,7 @@ def _static_built(name: str, config: CastleConfig) -> bool:
# ---------------------------------------------------------------------------
def is_active(name: str, kind: str, config: CastleConfig) -> bool:
def is_active(name: str, kind: str, config: WildpcConfig) -> bool:
"""Whether a deployment (name, kind) is available in its mode, by manager."""
manager = _svc_manager(name, kind, config)
if manager == "systemd":
@@ -157,17 +157,17 @@ def is_active(name: str, kind: str, config: CastleConfig) -> bool:
# ---------------------------------------------------------------------------
def enable_service(name: str, kind: str, config: CastleConfig) -> ActionResult:
def enable_service(name: str, kind: str, config: WildpcConfig) -> ActionResult:
"""Generate+install the unit (and timer) from the registry, enable and start it."""
if not REGISTRY_PATH.exists():
return ActionResult(
name, "activate", "error", "No registry. Run 'castle deploy' first."
name, "activate", "error", "No registry. Run 'wildpc deploy' first."
)
registry = load_registry()
deployed = registry.get(kind, name)
if deployed is None:
return ActionResult(
name, "activate", "error", f"'{name}' not in registry; run 'castle deploy'."
name, "activate", "error", f"'{name}' not in registry; run 'wildpc deploy'."
)
if not deployed.managed:
return ActionResult(
@@ -228,14 +228,14 @@ def disable_service(name: str, kind: str) -> ActionResult:
# ---------------------------------------------------------------------------
def _program_for(name: str, kind: str, config: CastleConfig):
def _program_for(name: str, kind: str, config: WildpcConfig):
"""The program a deployment runs (its `program` ref, defaulting to the name)."""
dep = config.deployment(kind, name)
prog = (dep.program if dep else None) or name
return prog, config.programs.get(prog)
async def activate(name: str, kind: str, config: CastleConfig, root: Path) -> ActionResult:
async def activate(name: str, kind: str, config: WildpcConfig, root: Path) -> ActionResult:
"""Make a deployment (name, kind) available in its mode, dispatched by manager."""
manager = _svc_manager(name, kind, config)
@@ -251,9 +251,9 @@ async def activate(name: str, kind: str, config: CastleConfig, root: Path) -> Ac
if manager == "caddy":
# Served by the gateway — reload it so the route is live. Building the
# assets is `castle program build` (the program's concern), not activation.
# assets is `wildpc program build` (the program's concern), not activation.
subprocess.run(
["systemctl", "--user", "reload", unit_name("castle-gateway")], check=False
["systemctl", "--user", "reload", unit_name("wildpc-gateway")], check=False
)
return ActionResult(name, "activate", "ok", f"{name}: served via gateway")
@@ -275,7 +275,7 @@ async def activate(name: str, kind: str, config: CastleConfig, root: Path) -> Ac
return ActionResult(name, "activate", "error", f"'{name}' not found")
async def deactivate(name: str, kind: str, config: CastleConfig, root: Path) -> ActionResult:
async def deactivate(name: str, kind: str, config: WildpcConfig, root: Path) -> ActionResult:
"""Take a deployment (name, kind) offline in its mode, dispatched by manager."""
manager = _svc_manager(name, kind, config)

View File

@@ -1,4 +1,4 @@
"""Castle manifest models — program specs, service specs, job specs."""
"""Wild PC manifest models — program specs, service specs, job specs."""
from __future__ import annotations
@@ -110,7 +110,7 @@ class RunCompose(LaunchBase):
"""A multi-container stack supervised as one unit via ``docker compose``.
Unlike ``container`` (a single ``docker run``), compose owns the stack's own
networking, startup ordering, and per-service health Castle delegates rather
networking, startup ordering, and per-service health Wild PC delegates rather
than reinventing orchestration. The unit runs ``compose up`` attached
(``Type=simple``) and tears the stack down via a generated ``ExecStop`` (down).
Env/secrets flow through systemd ``Environment=``/``EnvironmentFile=`` (from
@@ -119,7 +119,7 @@ class RunCompose(LaunchBase):
launcher: Literal["compose"]
file: str = "docker-compose.yml" # resolved relative to the program source
project_name: str | None = None # ``-p``; defaults to ``castle-<name>``
project_name: str | None = None # ``-p``; defaults to ``wildpc-<name>``
LaunchSpec = Annotated[
@@ -184,9 +184,9 @@ class HttpExposeSpec(BaseModel):
class TlsMaterial(str, Enum):
"""What cert files castle materializes onto a service from the wildcard cert.
"""What cert files wildpc materializes onto a service from the wildcard cert.
``off`` the service does its own TLS (or none); castle stays out of it.
``off`` the service does its own TLS (or none); wildpc stays out of it.
``pair`` cert.pem + key.pem (postgres, redis, most daemons).
``combined`` one file: key+cert concatenated (mongodb, haproxy, ).
"""
@@ -197,15 +197,15 @@ class TlsMaterial(str, Enum):
class TlsSpec(BaseModel):
"""Castle-managed TLS material for a raw-TCP service, cut from the gateway's
"""Wild PC-managed TLS material for a raw-TCP service, cut from the gateway's
ACME wildcard cert (valid for ``<name>.<domain>``) and refreshed on renewal.
The service consumes the materialized files via the ``${tls_*}`` placeholders.
"""
material: TlsMaterial = TlsMaterial.OFF
# Optional zero-downtime reload argv (a single command) run after the cert is
# re-materialized on renewal — e.g. ["systemctl", "--user", "reload", "castle-postgres"].
# Default (None): castle restarts the deployment (fine at a ~60-day cadence).
# re-materialized on renewal — e.g. ["systemctl", "--user", "reload", "wildpc-postgres"].
# Default (None): wildpc restarts the deployment (fine at a ~60-day cadence).
reload: list[str] | None = None
@@ -214,8 +214,8 @@ class TcpExposeSpec(BaseModel):
with ``reach: internal`` it's reachable at ``<name>.<domain>:<port>`` via the
wildcard DNS record + the bound port (no Caddy route). Publishing the port on
the LAN is the deployment's own job (a container's ``run.ports``, or a native
service binding ``0.0.0.0``); castle doesn't rebind it, so there's no bind-host
field here to imply otherwise. ``tls`` (optional) has castle drop the wildcard
service binding ``0.0.0.0``); wildpc doesn't rebind it, so there's no bind-host
field here to imply otherwise. ``tls`` (optional) has wildpc drop the wildcard
cert onto the service so it presents a trusted cert for ``<name>.<domain>``.
"""
@@ -281,7 +281,7 @@ class CommandsSpec(BaseModel):
class Requirement(BaseModel):
"""A precondition — another **deployment** that must exist for this one to be
*functional* (``ref`` = the target deployment's name). ``bind`` names the env
var castle projects the target's URL into — env is derived *from* the
var wildpc projects the target's URL into — env is derived *from* the
requirement, never scraped back into it.
A deployment declares these in its ``requires`` list; ``kind`` defaults to
@@ -315,7 +315,7 @@ class SessionsSpec(BaseModel):
"""Declarative session-history capability for an agent (optional).
Lets the dashboard show a unified picker of an agent's *own* past sessions
without castle knowing anything agent-specific in code: it runs
without wildpc knowing anything agent-specific in code: it runs
``list_command`` (which must print a JSON array of session objects), reads
three named fields off each object, and launches ``command`` + ``resume``
(with ``{id}`` substituted) to reopen one. Field names default to opencode's
@@ -333,7 +333,7 @@ class SessionsSpec(BaseModel):
class AgentSpec(BaseModel):
"""A launchable agent CLI for the dashboard's terminal UX.
Castle just runs ``command args`` inside a pty at ``cwd``; it never parses
Wild PC just runs ``command args`` inside a pty at ``cwd``; it never parses
the agent's output, so any interactive CLI works. This block only names the
launch live sessions (list/resume/kill) are a runtime concern, not config.
"""
@@ -341,11 +341,11 @@ class AgentSpec(BaseModel):
command: str
args: list[str] = Field(default_factory=list)
description: str | None = None
cwd: str | None = None # defaults to the castle repo root when unset
cwd: str | None = None # defaults to the wildpc repo root when unset
env: EnvMap = Field(default_factory=dict)
# Extra args that open the agent's own session browser / continue its last
# conversation (e.g. ["--resume"] or ["--continue"]). Optional and
# agent-specific — castle just passes them through. Empty = no such affordance.
# agent-specific — wildpc just passes them through. Empty = no such affordance.
resume_args: list[str] = Field(default_factory=list)
# Optional: declares how to list + resume the agent's own sessions, so the
# dashboard can render a unified session picker (see SessionsSpec).
@@ -364,7 +364,7 @@ class ProgramSpec(BaseModel):
description: str | None = None
# A program has NO kind of its own — kind is a *deployment* property. A program
# is a catalog entry that has 0..N deployments, each with its own kind (see
# kind_for and CastleConfig.deployments_of).
# kind_for and WildpcConfig.deployments_of).
source: str | None = None
stack: str | None = None
@@ -424,7 +424,7 @@ class DeploymentBase(BaseModel):
# is `- ref: <deployment>` (+ optional `bind: ENV_VAR` to project the target's
# URL into env). Drives the relationship graph's edges. See docs/relationships.md.
requires: list[Requirement] = Field(default_factory=list)
# Declared on/off state. `castle apply` converges reality to this: enabled
# Declared on/off state. `wildpc apply` converges reality to this: enabled
# deployments are activated (service started, tool installed, route served),
# disabled ones are deactivated but kept in the catalog. This is *desired
# state*, not a runtime toggle — the only way to durably stop something.
@@ -548,7 +548,7 @@ class PathDeployment(DeploymentBase):
manager: Literal["path"]
# An Anthropic Messages-API tool definition ({name, description, input_schema})
# for handing this CLI to an agent. Derived from the tool's ``--help`` (see
# ``castle_core.tool_schema``) but editable/overridable — a stored draft the
# ``wildpc_core.tool_schema``) but editable/overridable — a stored draft the
# completion-context builder reads. None → not yet generated.
tool_schema: dict | None = None

View File

@@ -8,7 +8,7 @@ from pathlib import Path
import yaml
from castle_core.config import CONTENT_DIR, SPECS_DIR
from wildpc_core.config import CONTENT_DIR, SPECS_DIR
REGISTRY_PATH = SPECS_DIR / "registry.yaml"
STATIC_DIR = CONTENT_DIR # backwards-compat alias
@@ -19,7 +19,7 @@ class NodeConfig:
"""Per-node identity and settings."""
hostname: str = ""
castle_root: str | None = None # repo path, for dev commands
wildpc_root: str | None = None # repo path, for dev commands
gateway_port: int = 9000
# None/"off" → HTTP-only; "internal" → Caddy local-CA HTTPS; "acme" → Let's
# Encrypt wildcard (*.gateway_domain) via DNS-01.
@@ -30,7 +30,7 @@ class NodeConfig:
# Cloudflare tunnel: public services publish at <subdomain>.<public_domain>.
public_domain: str | None = None
tunnel_id: str | None = None
# Emit the cert_obtained → `castle tls reconcile` hook (needs events-exec plugin).
# Emit the cert_obtained → `wildpc tls reconcile` hook (needs events-exec plugin).
cert_hook: bool = False
# Routable host peers use to reach this node's services (LAN IP/hostname).
# Defaults to the hostname; set explicitly when the hostname isn't resolvable
@@ -38,7 +38,7 @@ class NodeConfig:
address: str | None = None
# Fleet role: "authority" may write shared config/secrets to the mesh;
# "follower" reconciles from it. Static (no election) — the authority is
# pinned in castle.yaml. When the authority is down, shared state is
# pinned in wildpc.yaml. When the authority is down, shared state is
# read-only and every node keeps serving its own deployments from cache.
role: str = "follower"
@@ -98,7 +98,7 @@ class Deployment:
base_url: str | None = None
schedule: str | None = None
managed: bool = False
# Declared desired state (from the deployment's `enabled:`). `castle apply`
# Declared desired state (from the deployment's `enabled:`). `wildpc apply`
# activates enabled deployments and deactivates disabled ones. Default True.
enabled: bool = True
# Deployment `requires` (list of {kind, ref, bind}) — carried so the mesh can
@@ -139,14 +139,14 @@ class NodeRegistry:
def load_registry(path: Path | None = None) -> NodeRegistry:
"""Load the node registry from ~/.castle/registry.yaml."""
"""Load the node registry from ~/.wildpc/registry.yaml."""
if path is None:
path = REGISTRY_PATH
if not path.exists():
raise FileNotFoundError(
f"Registry not found: {path}\n"
"Run 'castle deploy' to generate it from castle.yaml."
"Run 'wildpc deploy' to generate it from wildpc.yaml."
)
with open(path) as f:
@@ -158,7 +158,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
node_data = data.get("node", {})
node = NodeConfig(
hostname=node_data.get("hostname", ""),
castle_root=node_data.get("castle_root"),
wildpc_root=node_data.get("wildpc_root"),
gateway_port=node_data.get("gateway_port", 9000),
gateway_tls=node_data.get("gateway_tls"),
gateway_domain=node_data.get("gateway_domain"),
@@ -207,7 +207,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
"""Write the node registry to ~/.castle/registry.yaml."""
"""Write the node registry to ~/.wildpc/registry.yaml."""
if path is None:
path = REGISTRY_PATH
@@ -221,8 +221,8 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
"deployed": {},
}
if registry.node.castle_root:
data["node"]["castle_root"] = registry.node.castle_root
if registry.node.wildpc_root:
data["node"]["wildpc_root"] = registry.node.wildpc_root
if registry.node.gateway_tls:
data["node"]["gateway_tls"] = registry.node.gateway_tls

View File

@@ -23,11 +23,11 @@ from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path
from castle_core import git
from castle_core.config import USER_TOOL_PATH_DIRS, CastleConfig
from castle_core.generators.systemd import runtime_path
from castle_core.manifest import Requirement, SystemdDeployment
from castle_core.stacks import ToolRequirement, tools_for
from wildpc_core import git
from wildpc_core.config import USER_TOOL_PATH_DIRS, WildpcConfig
from wildpc_core.generators.systemd import runtime_path
from wildpc_core.manifest import Requirement, SystemdDeployment
from wildpc_core.stacks import ToolRequirement, tools_for
@dataclass
@@ -102,7 +102,7 @@ def _slug(name: str, used: set[str]) -> str:
return key
def derive_repos(config: CastleConfig) -> dict[str, Repo]:
def derive_repos(config: WildpcConfig) -> dict[str, Repo]:
"""Group programs by the git working copy their source lives in."""
by_top: dict[str, list[str]] = {}
for pname, prog in config.programs.items():
@@ -132,7 +132,7 @@ def derive_repos(config: CastleConfig) -> dict[str, Repo]:
return repos
def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
def requirements_of(config: WildpcConfig, dep_name: str) -> list[Requirement]:
"""The full requirement set for a deployment: its own ``requires`` (deployment
dependencies), its program's ``system_dependencies`` synthesized as
``kind: system`` requirements, and its stack's toolchains synthesized as
@@ -159,7 +159,7 @@ def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
return out
def stack_tools_of(config: CastleConfig, dep_name: str) -> dict[str, ToolRequirement]:
def stack_tools_of(config: WildpcConfig, dep_name: str) -> dict[str, ToolRequirement]:
"""command → its :class:`ToolRequirement`, for every stack toolchain the
deployment(s) named ``dep_name`` need. The metadata (phase, install hint) behind
the ``kind: tool`` requirements ``requirements_of`` synthesizes used to check
@@ -210,7 +210,7 @@ def _tool_available(dep: object, tool: ToolRequirement) -> bool:
def _check(
config: CastleConfig,
config: WildpcConfig,
req: Requirement,
dep: object | None = None,
tool: ToolRequirement | None = None,
@@ -265,7 +265,7 @@ def _endpoints_of(dep: object) -> list[Endpoint]:
def build_model(
config: CastleConfig,
config: WildpcConfig,
check: bool = True,
active: set[str] | None = None,
freshness: bool = False,

View File

@@ -1,18 +1,18 @@
"""Pluggable secret backends for ``${secret:NAME}`` resolution.
Default is the **file** backend (``~/.castle/secrets/<name>``) identical to the
Default is the **file** backend (``~/.wildpc/secrets/<name>``) identical to the
historical behavior, so nothing changes unless a backend is explicitly selected
via ``CASTLE_SECRET_BACKEND``.
via ``WILDPC_SECRET_BACKEND``.
The **openbao** backend reads from an OpenBao/Vault KV-v2 mount and falls back to
the file backend, which is also how it bootstraps: the OpenBao *token* itself is a
file secret (it can't live in the vault it unlocks).
Selection (env, so it works in both the CLI and the systemd-run API):
CASTLE_SECRET_BACKEND file | openbao (default: file)
CASTLE_OPENBAO_ADDR http://localhost:8200
CASTLE_OPENBAO_MOUNT castle (kv-v2 mount path)
CASTLE_OPENBAO_TOKEN_SECRET OPENBAO_TOKEN (file secret holding the token)
WILDPC_SECRET_BACKEND file | openbao (default: file)
WILDPC_OPENBAO_ADDR http://localhost:8200
WILDPC_OPENBAO_MOUNT wildpc (kv-v2 mount path)
WILDPC_OPENBAO_TOKEN_SECRET OPENBAO_TOKEN (file secret holding the token)
"""
from __future__ import annotations
@@ -146,9 +146,9 @@ class OpenBaoBackend:
def build_backend(secrets_dir: Path, settings: dict | None = None) -> SecretBackend:
"""Construct the active secret backend.
Selection comes from ``settings`` (the ``secrets:`` block of castle.yaml), with
Selection comes from ``settings`` (the ``secrets:`` block of wildpc.yaml), with
environment variables overriding so production is configured declaratively in
castle.yaml while tests/CI can force a backend via env. Default: file.
wildpc.yaml while tests/CI can force a backend via env. Default: file.
The OpenBao **token** is still read from the file backend (the bootstrap root of
trust it can't live in the vault it unlocks); everything else comes from the
@@ -156,19 +156,19 @@ def build_backend(secrets_dir: Path, settings: dict | None = None) -> SecretBack
"""
settings = settings or {}
file_backend = FileSecretBackend(secrets_dir)
kind = (os.environ.get("CASTLE_SECRET_BACKEND") or settings.get("backend") or "file").lower()
kind = (os.environ.get("WILDPC_SECRET_BACKEND") or settings.get("backend") or "file").lower()
if kind == "openbao":
addr = os.environ.get("CASTLE_OPENBAO_ADDR") or settings.get(
addr = os.environ.get("WILDPC_OPENBAO_ADDR") or settings.get(
"addr"
) or "http://localhost:8200"
mount = os.environ.get("CASTLE_OPENBAO_MOUNT") or settings.get("mount") or "castle"
token_secret = os.environ.get("CASTLE_OPENBAO_TOKEN_SECRET") or settings.get(
mount = os.environ.get("WILDPC_OPENBAO_MOUNT") or settings.get("mount") or "wildpc"
token_secret = os.environ.get("WILDPC_OPENBAO_TOKEN_SECRET") or settings.get(
"token_secret"
) or "OPENBAO_TOKEN"
token = file_backend.read(token_secret) or os.environ.get(
"CASTLE_OPENBAO_TOKEN", ""
"WILDPC_OPENBAO_TOKEN", ""
)
node_prefix = os.environ.get("CASTLE_OPENBAO_NODE_PREFIX") or settings.get(
node_prefix = os.environ.get("WILDPC_OPENBAO_NODE_PREFIX") or settings.get(
"node_prefix"
)
return OpenBaoBackend(addr, token, mount, node_prefix)

View File

@@ -1,4 +1,4 @@
"""Stack dependency status — the derived, per-stack health the `castle stack`
"""Stack dependency status — the derived, per-stack health the `wildpc stack`
command, the ``GET /stacks`` API, and the dashboard Stacks page all render.
A *stack* declares the host toolchains it needs (``stacks.ToolRequirement``); this
@@ -15,10 +15,10 @@ import shutil
import subprocess
from dataclasses import dataclass, field
from castle_core.config import CastleConfig
from castle_core.generators.systemd import runtime_path
from castle_core.relations import _build_path, _tool_available
from castle_core.stacks import ToolRequirement, available_stacks, get_handler, tools_for
from wildpc_core.config import WildpcConfig
from wildpc_core.generators.systemd import runtime_path
from wildpc_core.relations import _build_path, _tool_available
from wildpc_core.stacks import ToolRequirement, available_stacks, get_handler, tools_for
@dataclass
@@ -96,9 +96,9 @@ def _tool_status(
def stack_status(
config: CastleConfig, name: str, *, with_version: bool = True
config: WildpcConfig, name: str, *, with_version: bool = True
) -> StackStatus | None:
"""The dependency status of one stack, or None if castle has no such handler."""
"""The dependency status of one stack, or None if wildpc has no such handler."""
handler = get_handler(name)
if handler is None:
return None
@@ -125,9 +125,9 @@ def stack_status(
def all_stack_status(
config: CastleConfig, *, with_version: bool = True
config: WildpcConfig, *, with_version: bool = True
) -> list[StackStatus]:
"""Dependency status for every stack castle knows — the Stacks catalog."""
"""Dependency status for every stack wildpc knows — the Stacks catalog."""
out = []
for name in available_stacks():
st = stack_status(config, name, with_version=with_version)

View File

@@ -9,9 +9,9 @@ import tomllib
from dataclasses import dataclass
from pathlib import Path
from castle_core.config import USER_TOOL_PATH_DIRS
from castle_core.manifest import ProgramSpec
from castle_core.toolchains import ToolchainError, resolve_node_bin
from wildpc_core.config import USER_TOOL_PATH_DIRS
from wildpc_core.manifest import ProgramSpec
from wildpc_core.toolchains import ToolchainError, resolve_node_bin
DEV_ACTIONS = ["build", "test", "lint", "format", "type-check", "check", "run"]
INSTALL_ACTIONS = ["install", "uninstall"]
@@ -45,13 +45,13 @@ class ActionResult:
@dataclass(frozen=True)
class ToolRequirement:
"""A host toolchain a stack needs — the declarative counterpart to the argv each
handler hard-codes (``uv sync``, ``pnpm build``, ). Made explicit so castle can
handler hard-codes (``uv sync``, ``pnpm build``, ). Made explicit so wildpc can
*check* whether the tool is present (on the box and, for run-phase tools, on the
service's own PATH) and, when it isn't, show a copyable fix instead of failing
mid-subprocess with a raw ``command not found``.
- ``phase`` when the tool is needed. ``build`` tools run only at
``castle apply``/build time (checked against the build env); ``run`` tools must
``wildpc apply``/build time (checked against the build env); ``run`` tools must
also be on the *running service's* PATH (the curated systemd env, which can
drift from your shell); ``both`` is checked in both places.
- ``install_hint`` the exact command a user can copy to install it.
@@ -70,9 +70,9 @@ def _build_env(node_source: Path | None = None) -> dict[str, str]:
"""Build a subprocess env with user tool dirs on PATH.
``node_source`` is the program's source dir: if it pins a node version (see
:mod:`castle_core.toolchains`), that node's bin dir goes on the front of PATH so
:mod:`wildpc_core.toolchains`), that node's bin dir goes on the front of PATH so
the verb uses the program's node instead of whatever ambient node the caller
happens to have (the CLI inherits your shell's; the castle-api build executor's
happens to have (the CLI inherits your shell's; the wildpc-api build executor's
default PATH has none). Raises :class:`ToolchainError` if the pin isn't installed.
"""
env = os.environ.copy()
@@ -111,7 +111,7 @@ async def _run(
def _vite_base(name: str) -> str:
"""The base path a castle-served static frontend builds against.
"""The base path a wildpc-served static frontend builds against.
Every frontend now serves at the **root of its own subdomain**
(`<name>.<gateway.domain>`), so the base is always '/'. Exposed to the build
@@ -130,7 +130,7 @@ class StackHandler:
"""Base class — subclasses implement each lifecycle action."""
# Whether this stack owns *persistent external state* (a database schema, a
# bucket, …) that outlives a code delete. Drives whether `castle delete`
# bucket, …) that outlives a code delete. Drives whether `wildpc delete`
# surfaces a data remnant / honors `--purge-data`. Overridden to True by the
# stacks whose `teardown` actually destroys something.
owns_data: bool = False
@@ -143,9 +143,9 @@ class StackHandler:
# lint/type-check/test also drops `check` implicitly.
provides: set[str] = _STACK_VERBS
# The host toolchains this stack needs, declared so castle can check them and
# The host toolchains this stack needs, declared so wildpc can check them and
# hint a fix. Empty by default (a stackless / declared-command program depends on
# nothing castle can name); each real handler overrides it. See `tools_for`.
# nothing wildpc can name); each real handler overrides it. See `tools_for`.
tools: tuple[ToolRequirement, ...] = ()
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
@@ -194,7 +194,7 @@ class StackHandler:
delete. Distinct from `uninstall` (which only takes a program offline).
Default: nothing to tear down. Only stacks that set ``owns_data`` and own
durable state override this; `castle delete --purge-data` invokes it.
durable state override this; `wildpc delete --purge-data` invokes it.
"""
return ActionResult(
program=name,
@@ -535,7 +535,7 @@ def _substrate_db_url() -> str | None:
explicit = os.environ.get("SUPABASE_DB_URL")
if explicit:
return explicit
from castle_core.config import read_secret
from wildpc_core.config import read_secret
pw = read_secret("SUPABASE_POSTGRES_PASSWORD")
if pw:
@@ -558,9 +558,9 @@ def app_schema(name: str) -> str:
# The privilege grant that makes an app schema reachable through PostgREST — the
# canonical Supabase "expose a custom schema" snippet. Idempotent; run before
# migrations so `alter default privileges` also covers the tables they create.
# Exposure ALSO requires the schema in the substrate's PGRST_DB_SCHEMAS — castle
# Exposure ALSO requires the schema in the substrate's PGRST_DB_SCHEMAS — wildpc
# derives that from the registered supabase apps (`${supabase_app_schemas}`), so a
# newly-added app needs a `castle deploy` + substrate restart to become routable.
# newly-added app needs a `wildpc deploy` + substrate restart to become routable.
def _schema_setup_sql(schema: str) -> str:
roles = "anon, authenticated, service_role"
return (
@@ -714,7 +714,7 @@ class SupabaseHandler(StackHandler):
"""Drop the app's schema and everything in it (tables, its own
schema_migrations, functions) in one statement total and knowable
because the app owns exactly one schema. The substrate's PGRST_DB_SCHEMAS
drops the (now-absent) schema on the next `castle deploy` + restart.
drops the (now-absent) schema on the next `wildpc deploy` + restart.
"""
schema = app_schema(name)
psql = shutil.which("psql")
@@ -745,7 +745,7 @@ class SupabaseHandler(StackHandler):
name,
"teardown",
"ok",
f"Dropped schema '{schema}' (all tables + rows). Run `castle deploy` "
f"Dropped schema '{schema}' (all tables + rows). Run `wildpc deploy` "
"and restart the substrate to prune it from PGRST_DB_SCHEMAS.",
)
@@ -806,7 +806,7 @@ def get_handler(stack: str | None) -> StackHandler | None:
def available_stacks() -> list[str]:
"""The stack names castle has handlers for — the single source of truth for the
"""The stack names wildpc has handlers for — the single source of truth for the
CLI ``--stack`` choices, the ``GET /stacks`` endpoint, and the dashboard select.
"""
return sorted(HANDLERS)
@@ -815,7 +815,7 @@ def available_stacks() -> list[str]:
def tools_for(stack: str | None) -> tuple[ToolRequirement, ...]:
"""The host toolchains a stack declares it needs (empty for an unknown/absent
stack). The single source of truth for the dependency checks in `relations`,
`castle stack`, `castle doctor`, and the dashboard Stacks page."""
`wildpc stack`, `wildpc doctor`, and the dashboard Stacks page."""
handler = get_handler(stack)
return handler.tools if handler is not None else ()

View File

@@ -1,8 +1,8 @@
"""Castle-managed TLS material for raw-TCP services.
"""Wild PC-managed TLS material for raw-TCP services.
Cuts the gateway's ACME wildcard cert (valid for ``<name>.<domain>``) onto a
service so it presents a *trusted* cert on its raw port, and refreshes it on
renewal. Protocol-agnostic: castle only copies files (in the requested format)
renewal. Protocol-agnostic: wildpc only copies files (in the requested format)
and signals the service each deployment declares the format (``pair`` /
``combined``) and how it consumes the files (via the ``${tls_*}`` placeholders).
@@ -10,7 +10,7 @@ Two entry points:
- ``materialize_all`` write/refresh the cert files, no reload (used by ``apply``,
which (re)starts the service itself).
- ``reconcile_tls`` materialize *and* reload the services whose cert changed
(used by ``castle tls reconcile`` and the Caddy ``cert_obtained`` hook).
(used by ``wildpc tls reconcile`` and the Caddy ``cert_obtained`` hook).
"""
from __future__ import annotations
@@ -21,8 +21,8 @@ import subprocess
import time
from pathlib import Path
from castle_core.config import CastleConfig
from castle_core.manifest import SystemdDeployment, TlsMaterial
from wildpc_core.config import WildpcConfig
from wildpc_core.manifest import SystemdDeployment, TlsMaterial
_KEY_MODE_FILES = {"key.pem", "combined.pem"} # secret → 0600; certs → 0644
@@ -37,7 +37,7 @@ def wildcard_cert(domain: str) -> tuple[Path, Path] | None:
"""``(crt, key)`` for ``*.<domain>`` from Caddy's store, or None.
Caddy stores it at ``certificates/<acme-dir>/wildcard_.<domain>/``. Prefer a
production cert over staging (``CASTLE_ACME_STAGING=1`` yields a staging dir).
production cert over staging (``WILDPC_ACME_STAGING=1`` yields a staging dir).
The ``.crt`` is the full chain (leaf + intermediates).
"""
store = _caddy_data_dir() / "certificates"
@@ -102,7 +102,7 @@ def _wanted_files(
return files
def materialize_tls(config: CastleConfig, name: str, dep: object) -> bool:
def materialize_tls(config: WildpcConfig, name: str, dep: object) -> bool:
"""Write ``dep``'s cert files from the wildcard, in its declared format.
Idempotent: returns ``False`` (no write) when the on-disk copy already matches
@@ -144,7 +144,7 @@ def materialize_tls(config: CastleConfig, name: str, dep: object) -> bool:
def materialize_all(
config: CastleConfig,
config: WildpcConfig,
messages: list[str] | None = None,
only: list[str] | None = None,
) -> list[str]:
@@ -152,10 +152,10 @@ def materialize_all(
which starts/restarts the services itself.
``only`` scopes materialization to the given deployment names (what a scoped
``castle apply <name>`` is converging). Left None every deployment. Scoping
``wildpc apply <name>`` is converging). Left None every deployment. Scoping
keeps a scoped apply from rewriting an *unrelated* service's cert on disk
without also reloading it (which would leave the file diverged from the running
process until the next ``castle tls reconcile``)."""
process until the next ``wildpc tls reconcile``)."""
msgs = messages if messages is not None else []
scope = set(only) if only is not None else None
for _kind, name, dep in config.all_deployments():
@@ -169,14 +169,14 @@ def materialize_all(
def wait_for_wildcard(
config: CastleConfig,
config: WildpcConfig,
names: list[str],
messages: list[str] | None = None,
timeout: float = 120.0,
interval: float = 3.0,
) -> list[str]:
"""Block until the ACME wildcard cert exists, when an in-scope deployment needs
castle-materialized TLS but the cert isn't issued yet.
wildpc-materialized TLS but the cert isn't issued yet.
On a fresh node the gateway reload during ``apply`` only *kicks off* DNS-01
issuance of ``*.<domain>`` (seconds to a couple minutes); materializing right
@@ -184,7 +184,7 @@ def wait_for_wildcard(
and with ``gateway.cert_hook`` disabled (the default) nothing would later
reconcile it. Waiting here lets ``apply`` bring the service up with its cert in
place on first deploy. Bounded: on timeout it warns and returns so ``apply``
still proceeds (rerun ``castle tls reconcile`` once the cert lands)."""
still proceeds (rerun ``wildpc tls reconcile`` once the cert lands)."""
msgs = messages if messages is not None else []
needs = [
n
@@ -205,7 +205,7 @@ def wait_for_wildcard(
return msgs
msgs.append(
f"tls: wildcard *.{domain} not ready after {int(timeout)}s — "
f"{', '.join(needs)} may start without a cert; rerun `castle tls reconcile` "
f"{', '.join(needs)} may start without a cert; rerun `wildpc tls reconcile` "
"once it is issued"
)
return msgs
@@ -218,14 +218,14 @@ def _reload(name: str, tls: object, msgs: list[str]) -> None:
msgs.append(f"tls: reloaded {name} (reload command)")
else:
subprocess.run(
["systemctl", "--user", "restart", f"castle-{name}.service"], check=False
["systemctl", "--user", "restart", f"wildpc-{name}.service"], check=False
)
msgs.append(f"tls: restarted {name} to pick up rotated cert")
def reconcile_tls(config: CastleConfig, messages: list[str] | None = None) -> list[str]:
def reconcile_tls(config: WildpcConfig, messages: list[str] | None = None) -> list[str]:
"""Materialize certs and reload the services whose cert changed. Idempotent —
a no-op when nothing rotated. Invoked by ``castle tls reconcile`` and the Caddy
a no-op when nothing rotated. Invoked by ``wildpc tls reconcile`` and the Caddy
``cert_obtained`` hook. Logs its own outcome (the hook's ``exec`` swallows it)."""
msgs = messages if messages is not None else []
for _kind, name, dep in config.all_deployments():

View File

@@ -1,6 +1,6 @@
"""Derive LLM tool-call schemas from a CLI tool's ``--help``.
castle's in-process version of the standalone ``toolify`` tool: given a tool (a
wildpc's in-process version of the standalone ``toolify`` tool: given a tool (a
program with a ``path`` deployment), resolve its executable, run ``--help``, and
build a tool-call definition.
@@ -14,10 +14,10 @@ Two parameter shapes, chosen automatically:
Schemas are built and stored in a **neutral** core (``{name, description,
parameters}``); ``render_tool_schema`` wraps that in the Anthropic or OpenAI
envelope on read. castle's feed defaults to OpenAI (litellm-native).
envelope on read. wildpc's feed defaults to OpenAI (litellm-native).
The extraction is intentionally duplicated from ``toolify`` rather than shared
``toolify`` is a standalone program that must never depend on castle. No LLM is
``toolify`` is a standalone program that must never depend on wildpc. No LLM is
used; the output is a deterministic function of the tool's ``--help``.
"""
@@ -31,7 +31,7 @@ from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from castle_core.config import CastleConfig
from wildpc_core.config import WildpcConfig
__all__ = [
"ToolSchemaError",
@@ -252,7 +252,7 @@ def _command_core(name: str, argv: list[str], help_text: str, deep: bool) -> dic
}
def tool_executable(config: CastleConfig, name: str) -> str:
def tool_executable(config: WildpcConfig, name: str) -> str:
"""The console script to invoke for tool ``name`` — its first
``[project.scripts]`` key (source of truth even when uninstalled), else the
program name. Mirrors the CLI's tools lens."""
@@ -271,7 +271,7 @@ def tool_executable(config: CastleConfig, name: str) -> str:
return name
def collect_tool_help(config: CastleConfig, name: str) -> str:
def collect_tool_help(config: WildpcConfig, name: str) -> str:
"""The full recursive ``--help`` text for tool ``name`` — top-level plus each
subcommand's help, the raw material an LLM assist reads to structure a
subcommand tree. Deterministic; raises ``ToolSchemaError`` if unresolved.
@@ -281,7 +281,7 @@ def collect_tool_help(config: CastleConfig, name: str) -> str:
if exe is None:
raise ToolSchemaError(
f"`{exe_name}` is not on PATH — install the tool (enable it, then "
f"`castle apply`) before generating its schema."
f"`wildpc apply`) before generating its schema."
)
_, help_text = _run_help([exe])
if not help_text:
@@ -345,7 +345,7 @@ def is_tool_schema_core(obj: object) -> bool:
return not validate_tool_schema_core(obj)
def derive_tool_schema(config: CastleConfig, name: str, deep: bool = False) -> dict:
def derive_tool_schema(config: WildpcConfig, name: str, deep: bool = False) -> dict:
"""Derive the neutral tool-call core for tool ``name`` from its ``--help``.
Structured params when the help is standard and flat; the command-string
@@ -357,7 +357,7 @@ def derive_tool_schema(config: CastleConfig, name: str, deep: bool = False) -> d
if exe is None:
raise ToolSchemaError(
f"`{exe_name}` is not on PATH — install the tool (enable it, then "
f"`castle apply`) before generating its schema."
f"`wildpc apply`) before generating its schema."
)
_, help_text = _run_help([exe])
if not help_text:

View File

@@ -3,18 +3,18 @@ binary directory on this box.
Today this covers **node**. Programs pin their node version the ecosystem-standard
way a ``.node-version`` / ``.nvmrc`` file, or ``package.json`` ``engines.node`` /
``volta.node`` so a program stays castle-independent (the prime directive: regular
programs never depend on castle). Castle *reads* that pin and resolves it to an
``volta.node`` so a program stays wildpc-independent (the prime directive: regular
programs never depend on wildpc). Wild PC *reads* that pin and resolves it to an
absolute ``.../bin`` directory it can put on PATH, at both execution sites that run a
program's node:
- **build time** the dev-verb subprocess (``stacks._build_env``), so ``castle
- **build time** the dev-verb subprocess (``stacks._build_env``), so ``wildpc
program build`` uses the program's node regardless of who triggers it (your shell
or the castle-api build executor);
or the wildpc-api build executor);
- **run time** a ``launcher: node`` service's systemd unit PATH (via
``deploy._build_deployed`` the generated unit's ``Environment=PATH``).
Resolution scans nvm's versioned install layout (``CASTLE_NODE_VERSIONS_DIR``,
Resolution scans nvm's versioned install layout (``WILDPC_NODE_VERSIONS_DIR``,
default ``~/.nvm/versions/node``) the versioned dir the default tool PATH
intentionally omits. A pinned-but-not-installed version fails loud with an
actionable ``nvm install`` hint rather than surfacing later as ``node: not found``.
@@ -41,7 +41,7 @@ class ToolchainError(Exception):
def node_versions_dir() -> Path:
"""The directory nvm installs versioned node under (env-overridable)."""
override = os.environ.get("CASTLE_NODE_VERSIONS_DIR")
override = os.environ.get("WILDPC_NODE_VERSIONS_DIR")
return Path(override) if override else Path.home() / ".nvm" / "versions" / "node"
@@ -86,7 +86,7 @@ def _installed() -> list[tuple[tuple[int, int, int], Path]]:
def resolve_node_bin(source: Path | None) -> Path | None:
"""Resolve a program's pinned node version to its ``.../bin`` dir on this box.
Returns ``None`` when the program pins nothing castle injects no node, it does
Returns ``None`` when the program pins nothing wildpc injects no node, it does
not guess. Raises :class:`ToolchainError` when a version *is* pinned but no
matching version is installed.

View File

@@ -1,4 +1,4 @@
"""Shared fixtures for castle core tests."""
"""Shared fixtures for wildpc core tests."""
from __future__ import annotations
@@ -6,9 +6,9 @@ import os as _os
from collections.abc import Generator
from pathlib import Path
# Tests must not read the host's real secret backend (castle.yaml may point
# Tests must not read the host's real secret backend (wildpc.yaml may point
# at OpenBao); force the file backend unless CI explicitly overrides.
_os.environ.setdefault("CASTLE_SECRET_BACKEND", "file")
_os.environ.setdefault("WILDPC_SECRET_BACKEND", "file")
import pytest
import yaml
@@ -64,16 +64,16 @@ def _store_for(spec: dict) -> str:
}[spec["manager"]]
def write_castle_config(root: Path, config: dict) -> None:
"""Scatter a nested castle config dict into the on-disk layout.
def write_wildpc_config(root: Path, config: dict) -> None:
"""Scatter a nested wildpc config dict into the on-disk layout.
`config` uses the terse nested shape (gateway/repo at top level, plus
programs/services/jobs mappings); this writes castle.yaml with globals, one file
programs/services/jobs mappings); this writes wildpc.yaml with globals, one file
per program under programs/, and each deployment under deployments/<kind>/ after
modernizing its legacy fields (see `_modernize_deployment`).
"""
globals_data = {k: v for k, v in config.items() if k in ("gateway", "repo")}
(root / "castle.yaml").write_text(yaml.dump(globals_data, default_flow_style=False))
(root / "wildpc.yaml").write_text(yaml.dump(globals_data, default_flow_style=False))
programs = config.get("programs") or {}
if programs:
@@ -94,8 +94,8 @@ def write_castle_config(root: Path, config: dict) -> None:
@pytest.fixture
def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"""Create a temporary castle root with directory-per-resource config."""
def wildpc_root(tmp_path: Path) -> Generator[Path, None, None]:
"""Create a temporary wildpc root with directory-per-resource config."""
config = {
"gateway": {"port": 18000},
"programs": {
@@ -141,7 +141,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
},
},
}
write_castle_config(tmp_path, config)
write_wildpc_config(tmp_path, config)
# Create project directories
svc_dir = tmp_path / "test-svc"
@@ -155,9 +155,9 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
@pytest.fixture
def castle_home(tmp_path: Path) -> Generator[Path, None, None]:
"""Create a temporary ~/.castle directory."""
home = tmp_path / ".castle"
def wildpc_home(tmp_path: Path) -> Generator[Path, None, None]:
"""Create a temporary ~/.wildpc directory."""
home = tmp_path / ".wildpc"
home.mkdir()
(home / "generated").mkdir()
(home / "secrets").mkdir()

View File

@@ -1,4 +1,4 @@
"""Tests for `castle apply` convergence — the diff classification (plan mode).
"""Tests for `wildpc apply` convergence — the diff classification (plan mode).
Plan mode computes the activate/restart/deactivate/unchanged buckets without
writing or touching the runtime, so it's the deterministic way to test the diff.
@@ -11,78 +11,78 @@ from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import castle_core.deploy as deploy_mod
from castle_core.config import load_config
from castle_core.deploy import (
import wildpc_core.deploy as deploy_mod
from wildpc_core.config import load_config
from wildpc_core.deploy import (
_desired_registry,
_gateway_would_change,
_render_unit_preview,
apply,
generate_caddyfile_from_registry,
)
from castle_core.registry import Deployment
from wildpc_core.registry import Deployment
def _add_static(castle_root: Path, name: str = "test-static") -> None:
"""Write a caddy (static) program + deployment into an existing castle root."""
(castle_root / "programs" / f"{name}.yaml").write_text(
f"description: Static {name}\nsource: {castle_root / name}\n"
def _add_static(wildpc_root: Path, name: str = "test-static") -> None:
"""Write a caddy (static) program + deployment into an existing wildpc root."""
(wildpc_root / "programs" / f"{name}.yaml").write_text(
f"description: Static {name}\nsource: {wildpc_root / name}\n"
)
statics = castle_root / "deployments" / "statics"
statics = wildpc_root / "deployments" / "statics"
statics.mkdir(parents=True, exist_ok=True)
(statics / f"{name}.yaml").write_text(
f"program: {name}\nmanager: caddy\nroot: public\nreach: internal\n"
)
(castle_root / name / "public").mkdir(parents=True, exist_ok=True)
(wildpc_root / name / "public").mkdir(parents=True, exist_ok=True)
def _plan(castle_root: Path, active: dict[str, bool]):
def _plan(wildpc_root: Path, active: dict[str, bool]):
"""Run apply(plan=True) with is_active stubbed to `active` (default False)."""
with patch(
"castle_core.lifecycle.is_active",
"wildpc_core.lifecycle.is_active",
side_effect=lambda n, k, c: active.get(n, False),
):
return apply(root=castle_root, plan=True)
return apply(root=wildpc_root, plan=True)
class TestApplyPlan:
def test_fresh_converge_activates_enabled(self, castle_root: Path) -> None:
def test_fresh_converge_activates_enabled(self, wildpc_root: Path) -> None:
"""Nothing running → every enabled deployment is 'activate'; no writes."""
result = _plan(castle_root, active={})
result = _plan(wildpc_root, active={})
assert result.planned is True
assert set(result.activated) == {"test-svc", "test-tool", "test-job"}
assert result.deactivated == []
assert result.restarted == []
def test_disabled_active_deployment_deactivates(self, castle_root: Path) -> None:
def test_disabled_active_deployment_deactivates(self, wildpc_root: Path) -> None:
"""A deployment with enabled:false that's currently up → 'deactivate'."""
# Turn the tool off in config.
tool = castle_root / "deployments" / "tools" / "test-tool.yaml"
tool = wildpc_root / "deployments" / "tools" / "test-tool.yaml"
tool.write_text(tool.read_text() + "enabled: false\n")
result = _plan(castle_root, active={"test-tool": True})
result = _plan(wildpc_root, active={"test-tool": True})
assert "test-tool" in result.deactivated
assert "test-tool" not in result.activated
def test_disabled_inactive_is_unchanged(self, castle_root: Path) -> None:
def test_disabled_inactive_is_unchanged(self, wildpc_root: Path) -> None:
"""enabled:false and already down → nothing to do."""
tool = castle_root / "deployments" / "tools" / "test-tool.yaml"
tool = wildpc_root / "deployments" / "tools" / "test-tool.yaml"
tool.write_text(tool.read_text() + "enabled: false\n")
result = _plan(castle_root, active={})
result = _plan(wildpc_root, active={})
assert "test-tool" in result.unchanged
assert "test-tool" not in result.deactivated
def test_active_service_with_changed_unit_restarts(self, castle_root: Path) -> None:
def test_active_service_with_changed_unit_restarts(self, wildpc_root: Path) -> None:
"""An up systemd service whose rendered unit differs from disk → 'restart'.
The temp home has no prior unit file (before-bytes = None), so any live
systemd deployment classifies as changed → restart, not a silent no-op.
"""
result = _plan(castle_root, active={"test-svc": True})
result = _plan(wildpc_root, active={"test-svc": True})
assert "test-svc" in result.restarted
assert "test-svc" not in result.activated
@@ -95,35 +95,35 @@ class TestGatewayChange:
would-be Caddyfile/tunnel config against disk — otherwise a new/changed static
route reports a false 'already converged'.
SPECS_DIR is the real ~/.castle path (unpatched by the fixtures), so these
SPECS_DIR is the real ~/.wildpc path (unpatched by the fixtures), so these
redirect it to a temp dir to stay hermetic and never touch the live Caddyfile.
"""
def test_new_route_reports_gateway_changed(
self, castle_root: Path, tmp_path: Path, monkeypatch
self, wildpc_root: Path, tmp_path: Path, monkeypatch
) -> None:
"""A static whose route isn't on disk yet → gateway_changed, even when the
assets already exist so the deployment itself classifies 'unchanged'."""
monkeypatch.setattr(deploy_mod, "SPECS_DIR", tmp_path / "specs")
_add_static(castle_root)
_add_static(wildpc_root)
# Static is 'active' (built) → _classify buckets it 'unchanged'; the route is
# still absent from the (missing) Caddyfile, so the gateway did change.
result = _plan(castle_root, active={"test-static": True})
result = _plan(wildpc_root, active={"test-static": True})
assert "test-static" in result.unchanged
assert result.gateway_changed is True
assert result.changed is True
def test_converged_caddyfile_is_not_changed(
self, castle_root: Path, tmp_path: Path, monkeypatch
self, wildpc_root: Path, tmp_path: Path, monkeypatch
) -> None:
"""When the on-disk Caddyfile already matches the desired one, no change."""
specs = tmp_path / "specs"
specs.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(deploy_mod, "SPECS_DIR", specs)
_add_static(castle_root)
config = load_config(castle_root)
_add_static(wildpc_root)
config = load_config(wildpc_root)
(specs / "Caddyfile").write_text(
generate_caddyfile_from_registry(_desired_registry(config, None))

View File

@@ -1,10 +1,10 @@
"""Tests for the consumption audit (core/src/castle_core/audit.py)."""
"""Tests for the consumption audit (core/src/wildpc_core/audit.py)."""
from __future__ import annotations
import castle_core.config as C
from castle_core import audit
from castle_core.manifest import SystemdDeployment
import wildpc_core.config as C
from wildpc_core import audit
from wildpc_core.manifest import SystemdDeployment
def _svc(
@@ -33,8 +33,8 @@ def _svc(
return SystemdDeployment.model_validate(spec)
def _cfg(deployments: dict) -> C.CastleConfig:
return C.CastleConfig(
def _cfg(deployments: dict) -> C.WildpcConfig:
return C.WildpcConfig(
root=None,
gateway=C.GatewayConfig(port=9000),
repo=None,
@@ -43,7 +43,7 @@ def _cfg(deployments: dict) -> C.CastleConfig:
)
def _pairs(cfg: C.CastleConfig) -> set[tuple[str, str]]:
def _pairs(cfg: C.WildpcConfig) -> set[tuple[str, str]]:
return {(s.consumer, s.provider) for s in audit.suggest_consumption(cfg)}
@@ -55,7 +55,7 @@ def test_split_host_port_pair_is_suggested() -> None:
"api": _svc(
"api",
http=9020,
env={"CASTLE_API_MQTT_HOST": "localhost", "CASTLE_API_MQTT_PORT": "1883"},
env={"WILDPC_API_MQTT_HOST": "localhost", "WILDPC_API_MQTT_PORT": "1883"},
),
}
)

View File

@@ -4,12 +4,12 @@ from __future__ import annotations
import pytest
from castle_core.config import CastleConfig, GatewayConfig
from castle_core.generators.caddyfile import (
from wildpc_core.config import WildpcConfig, GatewayConfig
from wildpc_core.generators.caddyfile import (
compute_routes,
generate_caddyfile_from_registry,
)
from castle_core.manifest import (
from wildpc_core.manifest import (
CaddyDeployment,
DeploymentSpec,
ExposeSpec,
@@ -20,14 +20,14 @@ from castle_core.manifest import (
RunPython,
SystemdDeployment,
)
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
from wildpc_core.registry import Deployment, NodeConfig, NodeRegistry
@pytest.fixture(autouse=True)
def _isolate_config(monkeypatch: pytest.MonkeyPatch) -> None:
"""Isolate the generator from the real ~/.castle config so static-frontend
"""Isolate the generator from the real ~/.wildpc config so static-frontend
routes don't leak into these registry-focused tests."""
import castle_core.config as config_mod
import wildpc_core.config as config_mod
def _no_config(*args: object, **kwargs: object) -> object:
raise FileNotFoundError("isolated in tests")
@@ -101,7 +101,7 @@ class TestAcmeMode:
def test_port_9000_redirects_to_dashboard(self) -> None:
cf = generate_caddyfile_from_registry(_acme({"api": _dep(9020, expose=True, name="api")}))
assert ":9000 {" in cf
assert "redir https://castle.example.com{uri}" in cf
assert "redir https://wildpc.example.com{uri}" in cf
def test_no_path_routes(self) -> None:
cf = generate_caddyfile_from_registry(_acme({"api": _dep(9020, expose=True, name="api")}))
@@ -114,7 +114,7 @@ class TestAcmeMode:
assert "pg.example.com" not in cf
def test_staging_toggle(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CASTLE_ACME_STAGING", "1")
monkeypatch.setenv("WILDPC_ACME_STAGING", "1")
cf = generate_caddyfile_from_registry(_acme({"api": _dep(9020, expose=True, name="api")}))
assert "acme_ca https://acme-staging-v02.api.letsencrypt.org/directory" in cf
@@ -122,20 +122,20 @@ class TestAcmeMode:
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# A static frontend is a `runner: static` service serving <source>/<root>.
import castle_core.config as config_mod
import wildpc_core.config as config_mod
cfg = _config(
deployments={
"castle": CaddyDeployment(
manager="caddy", program="castle", root="dist"
"wildpc": CaddyDeployment(
manager="caddy", program="wildpc", root="dist"
)
},
programs={"castle": ProgramSpec(source="/data/repos/castle/app")},
programs={"wildpc": ProgramSpec(source="/data/repos/wildpc/app")},
)
monkeypatch.setattr(config_mod, "load_config", lambda *a, **k: cfg)
cf = generate_caddyfile_from_registry(_acme({}))
assert "@host_castle host castle.example.com" in cf
assert "root * /data/repos/castle/app/dist" in cf
assert "@host_wildpc host wildpc.example.com" in cf
assert "root * /data/repos/wildpc/app/dist" in cf
assert "try_files {path} /index.html" in cf
assert "file_server" in cf
@@ -187,12 +187,12 @@ class TestPublicExposure:
class TestOffMode:
"""No domain → HTTP-only control plane on :<port>: dashboard at / + /api → castle-api.
"""No domain → HTTP-only control plane on :<port>: dashboard at / + /api → wildpc-api.
Other services are port-only (not routed)."""
def test_control_plane(self) -> None:
cf = generate_caddyfile_from_registry(
_make_registry(deployed={"castle-api": _dep(9020, expose=True, name="castle-api")})
_make_registry(deployed={"wildpc-api": _dep(9020, expose=True, name="wildpc-api")})
)
assert "auto_https off" in cf
assert "handle_path /api/* {" in cf
@@ -217,8 +217,8 @@ def _service(port: int, *, expose: bool) -> SystemdDeployment:
def _config(
deployments: dict[str, DeploymentSpec], programs: dict[str, ProgramSpec] | None = None
) -> CastleConfig:
return CastleConfig(
) -> WildpcConfig:
return WildpcConfig(
root=None, # type: ignore[arg-type]
gateway=GatewayConfig(port=9000),
repo=None,
@@ -228,7 +228,7 @@ def _config(
class TestConfigSourceOfTruth:
"""compute_routes derives exposure/port from castle.yaml (the checkbox), so a
"""compute_routes derives exposure/port from wildpc.yaml (the checkbox), so a
regenerated Caddyfile tracks the spec, not a stale registry."""
def test_exposed_service_becomes_a_route(self) -> None:

View File

@@ -5,13 +5,13 @@ from __future__ import annotations
from pathlib import Path
import yaml
from castle_core.config import load_config
from castle_core.generators.caddyfile import compute_routes
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
from wildpc_core.config import load_config
from wildpc_core.generators.caddyfile import compute_routes
from wildpc_core.registry import Deployment, NodeConfig, NodeRegistry
def _config_requiring_widget(root: Path) -> None:
(root / "castle.yaml").write_text(yaml.safe_dump({"gateway": {"port": 18000}}))
(root / "wildpc.yaml").write_text(yaml.safe_dump({"gateway": {"port": 18000}}))
svc_dir = root / "deployments" / "services"
svc_dir.mkdir(parents=True)
(svc_dir / "consumer.yaml").write_text(
@@ -79,7 +79,7 @@ def test_breaker_no_route_when_peer_absent(tmp_path: Path) -> None:
def test_no_remote_route_for_unconsumed_service(tmp_path: Path) -> None:
"""A peer service nobody requires is not routed."""
(tmp_path / "castle.yaml").write_text(yaml.safe_dump({"gateway": {"port": 18000}}))
(tmp_path / "wildpc.yaml").write_text(yaml.safe_dump({"gateway": {"port": 18000}}))
config = load_config(tmp_path) # no requires anywhere
routes = compute_routes(
_local(), config, {"tower": _peer_with_widget("10.0.0.5")}

View File

@@ -1,4 +1,4 @@
"""Tests for castle configuration loading."""
"""Tests for wildpc configuration loading."""
from __future__ import annotations
@@ -6,138 +6,138 @@ from pathlib import Path
import pytest
import yaml
from castle_core.config import (
CastleConfig,
from wildpc_core.config import (
WildpcConfig,
load_config,
resolve_env_split,
resolve_env_vars,
save_config,
secret_refs,
)
from castle_core.manifest import ProgramSpec, SystemdDeployment
from wildpc_core.manifest import ProgramSpec, SystemdDeployment
class TestLoadConfig:
"""Tests for loading castle.yaml."""
"""Tests for loading wildpc.yaml."""
def test_load_basic(self, castle_root: Path) -> None:
"""Load a castle.yaml with three sections."""
config = load_config(castle_root)
assert isinstance(config, CastleConfig)
def test_load_basic(self, wildpc_root: Path) -> None:
"""Load a wildpc.yaml with three sections."""
config = load_config(wildpc_root)
assert isinstance(config, WildpcConfig)
assert config.gateway.port == 18000
assert "test-tool" in config.programs
assert "test-svc" in config.services
assert "test-job" in config.jobs
def test_load_produces_typed_specs(self, castle_root: Path) -> None:
def test_load_produces_typed_specs(self, wildpc_root: Path) -> None:
"""Each section produces the correct spec type."""
config = load_config(castle_root)
config = load_config(wildpc_root)
assert isinstance(config.programs["test-tool"], ProgramSpec)
# Both a service and a job are systemd deployments; the kind (service/job)
# is derived from whether a schedule is present.
assert isinstance(config.services["test-svc"], SystemdDeployment)
assert isinstance(config.jobs["test-job"], SystemdDeployment)
def test_service_expose(self, castle_root: Path) -> None:
def test_service_expose(self, wildpc_root: Path) -> None:
"""Service has correct expose spec."""
config = load_config(castle_root)
config = load_config(wildpc_root)
svc = config.services["test-svc"]
assert svc.expose.http.internal.port == 19000
assert svc.expose.http.health_path == "/health"
def test_service_proxy(self, castle_root: Path) -> None:
def test_service_proxy(self, wildpc_root: Path) -> None:
"""Service has correct proxy spec."""
config = load_config(castle_root)
config = load_config(wildpc_root)
svc = config.services["test-svc"]
assert svc.proxy is True # exposed at <name>.<gateway.domain>
def test_service_run_spec(self, castle_root: Path) -> None:
def test_service_run_spec(self, wildpc_root: Path) -> None:
"""Service has correct launch spec (legacy runner normalized to launcher)."""
config = load_config(castle_root)
config = load_config(wildpc_root)
svc = config.services["test-svc"]
assert svc.run.launcher == "python"
assert svc.run.program == "test-svc"
def test_service_component_ref(self, castle_root: Path) -> None:
def test_service_component_ref(self, wildpc_root: Path) -> None:
"""Service references a component."""
config = load_config(castle_root)
config = load_config(wildpc_root)
svc = config.services["test-svc"]
assert svc.program == "test-svc-comp"
def test_job_schedule(self, castle_root: Path) -> None:
def test_job_schedule(self, wildpc_root: Path) -> None:
"""Job has correct schedule."""
config = load_config(castle_root)
config = load_config(wildpc_root)
job = config.jobs["test-job"]
assert job.schedule == "0 2 * * *"
def test_tools_property(self, castle_root: Path) -> None:
def test_tools_property(self, wildpc_root: Path) -> None:
"""Tools property filters to components with install.path or tool."""
config = load_config(castle_root)
config = load_config(wildpc_root)
assert "test-tool" in config.tools
def test_missing_config_raises(self, tmp_path: Path) -> None:
"""Missing castle.yaml raises FileNotFoundError."""
"""Missing wildpc.yaml raises FileNotFoundError."""
with pytest.raises(FileNotFoundError):
load_config(tmp_path)
class TestSaveConfig:
"""Tests for saving castle.yaml."""
"""Tests for saving wildpc.yaml."""
def test_round_trip(self, castle_root: Path) -> None:
def test_round_trip(self, wildpc_root: Path) -> None:
"""Load and save should produce equivalent config."""
config = load_config(castle_root)
config = load_config(wildpc_root)
save_config(config)
config2 = load_config(castle_root)
config2 = load_config(wildpc_root)
assert config2.gateway.port == config.gateway.port
assert set(config2.programs.keys()) == set(config.programs.keys())
assert set(config2.services.keys()) == set(config.services.keys())
assert set(config2.jobs.keys()) == set(config.jobs.keys())
def test_save_adds_component(self, castle_root: Path) -> None:
def test_save_adds_component(self, wildpc_root: Path) -> None:
"""Adding a component and saving persists it."""
config = load_config(castle_root)
config = load_config(wildpc_root)
config.programs["new-lib"] = ProgramSpec(
id="new-lib", description="A new library"
)
save_config(config)
config2 = load_config(castle_root)
config2 = load_config(wildpc_root)
assert "new-lib" in config2.programs
assert config2.programs["new-lib"].description == "A new library"
def test_preserves_manage_systemd(self, castle_root: Path) -> None:
def test_preserves_manage_systemd(self, wildpc_root: Path) -> None:
"""Roundtrip preserves manage.systemd even with all defaults."""
config = load_config(castle_root)
config = load_config(wildpc_root)
save_config(config)
config2 = load_config(castle_root)
config2 = load_config(wildpc_root)
svc = config2.services["test-svc"]
assert svc.manage is not None
assert svc.manage.systemd is not None
def test_writes_directory_layout(self, castle_root: Path) -> None:
def test_writes_directory_layout(self, wildpc_root: Path) -> None:
"""Save writes one file per resource under programs/ and one deployments/ dir."""
config = load_config(castle_root)
config = load_config(wildpc_root)
save_config(config)
assert (castle_root / "programs" / "test-tool.yaml").exists()
assert (wildpc_root / "programs" / "test-tool.yaml").exists()
# Deployments live under per-kind subdirs (deployments/<store>/<name>.yaml).
assert (castle_root / "deployments" / "services" / "test-svc.yaml").exists()
assert (castle_root / "deployments" / "jobs" / "test-job.yaml").exists()
assert (castle_root / "deployments" / "tools" / "test-tool.yaml").exists()
assert (wildpc_root / "deployments" / "services" / "test-svc.yaml").exists()
assert (wildpc_root / "deployments" / "jobs" / "test-job.yaml").exists()
assert (wildpc_root / "deployments" / "tools" / "test-tool.yaml").exists()
# Global file holds gateway only, no resource sections
global_data = yaml.safe_load((castle_root / "castle.yaml").read_text())
global_data = yaml.safe_load((wildpc_root / "wildpc.yaml").read_text())
assert global_data["gateway"]["port"] == 18000
assert "programs" not in global_data
assert "deployments" not in global_data
def test_delete_prunes_file(self, castle_root: Path) -> None:
def test_delete_prunes_file(self, wildpc_root: Path) -> None:
"""Removing a deployment and saving deletes its on-disk file."""
config = load_config(castle_root)
config = load_config(wildpc_root)
del config.services["test-svc"]
save_config(config)
assert not (castle_root / "deployments" / "services" / "test-svc.yaml").exists()
config2 = load_config(castle_root)
assert not (wildpc_root / "deployments" / "services" / "test-svc.yaml").exists()
config2 = load_config(wildpc_root)
assert "test-svc" not in config2.services
assert "test-tool" in config2.programs
@@ -164,7 +164,7 @@ class TestResolveEnvVars:
secrets_dir = tmp_path / "secrets"
secrets_dir.mkdir()
(secrets_dir / "API_KEY").write_text("my-secret-key\n")
monkeypatch.setattr("castle_core.config.SECRETS_DIR", secrets_dir)
monkeypatch.setattr("wildpc_core.config.SECRETS_DIR", secrets_dir)
env = {"API_KEY": "${secret:API_KEY}"}
resolved = resolve_env_vars(env)
@@ -176,7 +176,7 @@ class TestResolveEnvVars:
"""Missing secret returns placeholder."""
secrets_dir = tmp_path / "secrets"
secrets_dir.mkdir()
monkeypatch.setattr("castle_core.config.SECRETS_DIR", secrets_dir)
monkeypatch.setattr("wildpc_core.config.SECRETS_DIR", secrets_dir)
env = {"API_KEY": "${secret:NONEXISTENT}"}
resolved = resolve_env_vars(env)
@@ -207,7 +207,7 @@ class TestResolveEnvSplit:
secrets_dir.mkdir()
for name, val in vals.items():
(secrets_dir / name).write_text(val + "\n")
monkeypatch.setattr("castle_core.config.SECRETS_DIR", secrets_dir)
monkeypatch.setattr("wildpc_core.config.SECRETS_DIR", secrets_dir)
def test_plain_only(self) -> None:
plain, secret = resolve_env_split({"PORT": "9001", "URL": "http://x"})
@@ -273,8 +273,8 @@ class TestConfigRoundTrip:
the next write — these lock the full round-trip for the reach/TCP-TLS fields."""
def test_gateway_and_tcp_tls_survive_save_load(self, tmp_path: Path) -> None:
from castle_core.config import GatewayConfig
from castle_core.manifest import (
from wildpc_core.config import GatewayConfig
from wildpc_core.manifest import (
ExposeSpec,
Reach,
RunContainer,
@@ -299,7 +299,7 @@ class TestConfigRoundTrip:
tcp=TcpExposeSpec(port=5432, tls=TlsSpec(material=TlsMaterial.PAIR))
),
)
config = CastleConfig(
config = WildpcConfig(
root=tmp_path,
gateway=GatewayConfig(
port=9000, tls="acme", domain="civil.payne.io", cert_hook=True
@@ -327,7 +327,7 @@ class TestConfigRoundTrip:
def test_parse_gateway_preserves_all_fields(self) -> None:
"""The shared gateway parser must honor every field — `save_yaml` used to
read only `port`, wiping tls/domain/tunnel/cert_hook on a whole-file save."""
from castle_core.config import parse_gateway
from wildpc_core.config import parse_gateway
g = parse_gateway(
{
@@ -349,27 +349,27 @@ class TestConfigRoundTrip:
class TestConfigurableRoots:
"""data_dir / repos_dir: env > castle.yaml > default. The single source of truth
"""data_dir / repos_dir: env > wildpc.yaml > default. The single source of truth
that keeps the CLI and the api service from resolving different data dirs."""
@pytest.fixture(autouse=True)
def _no_root_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
# The test host may export CASTLE_DATA_DIR (that's the bug we're fixing);
# The test host may export WILDPC_DATA_DIR (that's the bug we're fixing);
# clear it so yaml/default precedence is exercised deterministically.
monkeypatch.delenv("CASTLE_DATA_DIR", raising=False)
monkeypatch.delenv("CASTLE_REPOS_DIR", raising=False)
monkeypatch.delenv("WILDPC_DATA_DIR", raising=False)
monkeypatch.delenv("WILDPC_REPOS_DIR", raising=False)
def test_resolve_precedence_env_over_yaml(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from castle_core.config import _DEFAULT_DATA_DIR, _resolve_root_path
from wildpc_core.config import _DEFAULT_DATA_DIR, _resolve_root_path
monkeypatch.setenv("X_ROOT_ENV", "/from/env")
p = _resolve_root_path("X_ROOT_ENV", "/from/yaml", tmp_path, _DEFAULT_DATA_DIR)
assert p == Path("/from/env")
def test_resolve_yaml_over_default(self, tmp_path: Path) -> None:
from castle_core.config import _DEFAULT_DATA_DIR, _resolve_root_path
from wildpc_core.config import _DEFAULT_DATA_DIR, _resolve_root_path
p = _resolve_root_path(
"UNSET_ROOT_ENV", "/from/yaml", tmp_path, _DEFAULT_DATA_DIR
@@ -377,7 +377,7 @@ class TestConfigurableRoots:
assert p == Path("/from/yaml")
def test_resolve_default_when_neither(self, tmp_path: Path) -> None:
from castle_core.config import _DEFAULT_DATA_DIR, _resolve_root_path
from wildpc_core.config import _DEFAULT_DATA_DIR, _resolve_root_path
p = _resolve_root_path("UNSET_ROOT_ENV", None, tmp_path, _DEFAULT_DATA_DIR)
assert p == _DEFAULT_DATA_DIR # returned as-is so save_config can compare equal
@@ -385,16 +385,16 @@ class TestConfigurableRoots:
def test_resolve_expanduser(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from castle_core.config import _DEFAULT_DATA_DIR, _resolve_root_path
from wildpc_core.config import _DEFAULT_DATA_DIR, _resolve_root_path
monkeypatch.setenv("X_ROOT_ENV", "~/box")
p = _resolve_root_path("X_ROOT_ENV", None, tmp_path, _DEFAULT_DATA_DIR)
assert p == (Path.home() / "box").resolve()
def test_resolve_relative_anchored_to_anchor_not_cwd(self, tmp_path: Path) -> None:
"""A relative root is anchored to the dir holding castle.yaml — never cwd, or
"""A relative root is anchored to the dir holding wildpc.yaml — never cwd, or
the CLI (shell cwd) and api (unit cwd) would diverge again."""
from castle_core.config import _DEFAULT_DATA_DIR, _resolve_root_path
from wildpc_core.config import _DEFAULT_DATA_DIR, _resolve_root_path
p = _resolve_root_path(
"UNSET_ROOT_ENV", "sub/data", tmp_path, _DEFAULT_DATA_DIR
@@ -402,23 +402,23 @@ class TestConfigurableRoots:
assert p == (tmp_path / "sub" / "data").resolve()
def test_load_config_reads_data_dir_from_yaml(self, tmp_path: Path) -> None:
(tmp_path / "castle.yaml").write_text(
(tmp_path / "wildpc.yaml").write_text(
yaml.dump({"gateway": {"port": 9000}, "data_dir": "/srv/box/data"})
)
config = load_config(tmp_path)
assert config.data_dir == Path("/srv/box/data")
def test_load_config_data_dir_defaults(self, tmp_path: Path) -> None:
from castle_core.config import _DEFAULT_DATA_DIR
from wildpc_core.config import _DEFAULT_DATA_DIR
(tmp_path / "castle.yaml").write_text(yaml.dump({"gateway": {"port": 9000}}))
(tmp_path / "wildpc.yaml").write_text(yaml.dump({"gateway": {"port": 9000}}))
config = load_config(tmp_path)
assert config.data_dir == _DEFAULT_DATA_DIR
def test_save_round_trips_nondefault_roots(self, tmp_path: Path) -> None:
from castle_core.config import GatewayConfig
from wildpc_core.config import GatewayConfig
cfg = CastleConfig(
cfg = WildpcConfig(
root=tmp_path,
gateway=GatewayConfig(port=9000),
repo=None,
@@ -427,7 +427,7 @@ class TestConfigurableRoots:
repos_dir=Path("/srv/box/repos"),
)
save_config(cfg)
text = (tmp_path / "castle.yaml").read_text()
text = (tmp_path / "wildpc.yaml").read_text()
assert "data_dir: /srv/box/data" in text
assert "repos_dir: /srv/box/repos" in text
reloaded = load_config(tmp_path)
@@ -435,13 +435,13 @@ class TestConfigurableRoots:
assert reloaded.repos_dir == Path("/srv/box/repos")
def test_save_omits_default_roots(self, tmp_path: Path) -> None:
from castle_core.config import (
from wildpc_core.config import (
_DEFAULT_DATA_DIR,
_DEFAULT_REPOS_DIR,
GatewayConfig,
)
cfg = CastleConfig(
cfg = WildpcConfig(
root=tmp_path,
gateway=GatewayConfig(port=9000),
repo=None,
@@ -450,20 +450,20 @@ class TestConfigurableRoots:
repos_dir=_DEFAULT_REPOS_DIR,
)
save_config(cfg)
text = (tmp_path / "castle.yaml").read_text()
text = (tmp_path / "wildpc.yaml").read_text()
assert "data_dir" not in text
assert "repos_dir" not in text
def test_ensure_dirs_raises_actionable_error(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An uncreatable data dir yields a CastleDirError with a fix, not a bare OSError."""
import castle_core.config as C
from castle_core.config import GatewayConfig
"""An uncreatable data dir yields a WildpcDirError with a fix, not a bare OSError."""
import wildpc_core.config as C
from wildpc_core.config import GatewayConfig
# Redirect the in-$HOME dirs to tmp so the test never touches the real ~/.castle.
# Redirect the in-$HOME dirs to tmp so the test never touches the real ~/.wildpc.
for name in (
"CASTLE_HOME",
"WILDPC_HOME",
"CODE_DIR",
"SPECS_DIR",
"CONTENT_DIR",
@@ -474,13 +474,13 @@ class TestConfigurableRoots:
# deterministically, even if the suite runs as root.
blocker = tmp_path / "afile"
blocker.write_text("x")
cfg = CastleConfig(
cfg = WildpcConfig(
root=tmp_path,
gateway=GatewayConfig(port=9000),
repo=None,
programs={},
data_dir=blocker / "sub",
)
with pytest.raises(C.CastleDirError) as ei:
with pytest.raises(C.WildpcDirError) as ei:
C.ensure_dirs(cfg)
assert "data_dir" in str(ei.value)

View File

@@ -5,9 +5,9 @@ from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from castle_core import deploy as deploy_mod
from castle_core.deploy import _desired_unit_files, _prune_orphans
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
from wildpc_core import deploy as deploy_mod
from wildpc_core.deploy import _desired_unit_files, _prune_orphans
from wildpc_core.registry import Deployment, NodeConfig, NodeRegistry
def _svc(managed: bool = True, schedule: str | None = None) -> Deployment:
@@ -23,7 +23,7 @@ def _svc(managed: bool = True, schedule: str | None = None) -> Deployment:
def _registry(**deployed: Deployment) -> NodeRegistry:
reg = NodeRegistry(node=NodeConfig(castle_root="/x", gateway_port=9000))
reg = NodeRegistry(node=NodeConfig(wildpc_root="/x", gateway_port=9000))
for name, d in deployed.items():
d.name = name
reg.put(d)
@@ -38,11 +38,11 @@ def _touch(d: Path, *names: str) -> None:
class TestDesiredUnitFiles:
def test_managed_service_yields_service_file(self) -> None:
reg = _registry(foo=_svc())
assert _desired_unit_files(reg) == {"castle-foo.service"}
assert _desired_unit_files(reg) == {"wildpc-foo.service"}
def test_scheduled_job_yields_service_and_timer(self) -> None:
reg = _registry(job=_svc(schedule="0 2 * * *"))
assert _desired_unit_files(reg) == {"castle-job-job.service", "castle-job-job.timer"}
assert _desired_unit_files(reg) == {"wildpc-job-job.service", "wildpc-job-job.timer"}
def test_unmanaged_excluded(self) -> None:
reg = _registry(foo=_svc(managed=False))
@@ -54,7 +54,7 @@ class TestPruneOrphans:
units = tmp_path / "systemd"
units.mkdir()
# keep-me is in the registry; gone is not; gone.timer is also orphaned
_touch(units, "castle-keep.service", "castle-gone.service", "castle-gone.timer")
_touch(units, "wildpc-keep.service", "wildpc-gone.service", "wildpc-gone.timer")
reg = _registry(keep=_svc())
msgs: list[str] = []
with (
@@ -62,17 +62,17 @@ class TestPruneOrphans:
patch.object(deploy_mod.subprocess, "run") as mock_run,
):
_prune_orphans(reg, msgs)
assert (units / "castle-keep.service").exists()
assert not (units / "castle-gone.service").exists()
assert not (units / "castle-gone.timer").exists()
assert (units / "wildpc-keep.service").exists()
assert not (units / "wildpc-gone.service").exists()
assert not (units / "wildpc-gone.timer").exists()
# stop + disable were invoked for each removed unit
assert mock_run.call_count >= 2
assert any("castle-gone.service" in m for m in msgs)
assert any("wildpc-gone.service" in m for m in msgs)
def test_unscheduled_job_drops_stale_timer_keeps_service(self, tmp_path: Path) -> None:
units = tmp_path / "systemd"
units.mkdir()
_touch(units, "castle-job.service", "castle-job.timer")
_touch(units, "wildpc-job.service", "wildpc-job.timer")
# job is still managed but no longer scheduled → its timer is now an orphan
reg = _registry(job=_svc(schedule=None))
with (
@@ -80,17 +80,17 @@ class TestPruneOrphans:
patch.object(deploy_mod.subprocess, "run"),
):
_prune_orphans(reg, [])
assert (units / "castle-job.service").exists()
assert not (units / "castle-job.timer").exists()
assert (units / "wildpc-job.service").exists()
assert not (units / "wildpc-job.timer").exists()
def test_demoted_to_unmanaged_is_pruned(self, tmp_path: Path) -> None:
units = tmp_path / "systemd"
units.mkdir()
_touch(units, "castle-foo.service")
_touch(units, "wildpc-foo.service")
reg = _registry(foo=_svc(managed=False)) # still in registry but unmanaged
with (
patch.object(deploy_mod, "SYSTEMD_USER_DIR", units),
patch.object(deploy_mod.subprocess, "run"),
):
_prune_orphans(reg, [])
assert not (units / "castle-foo.service").exists()
assert not (units / "wildpc-foo.service").exists()

View File

@@ -5,14 +5,14 @@ from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from castle_core.deploy import _build_run_cmd, _build_stop_cmd
from castle_core.manifest import RunCompose, RunContainer, RunNode, RunPython
from wildpc_core.deploy import _build_run_cmd, _build_stop_cmd
from wildpc_core.manifest import RunCompose, RunContainer, RunNode, RunPython
def test_python_runner_uses_uv_run_from_source(tmp_path: Path) -> None:
"""A python service with a real source dir launches via `uv run --project`."""
run = RunPython(launcher="python", program="my-svc")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/uv"):
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/uv"):
cmd = _build_run_cmd("my-svc", run, {}, [], source_dir=tmp_path)
assert cmd == [
"/usr/bin/uv",
@@ -26,7 +26,7 @@ def test_python_runner_uses_uv_run_from_source(tmp_path: Path) -> None:
def test_python_runner_appends_args(tmp_path: Path) -> None:
run = RunPython(launcher="python", program="my-svc", args=["--flag", "x"])
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/uv"):
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/uv"):
cmd = _build_run_cmd("my-svc", run, {}, [], source_dir=tmp_path)
assert cmd[-2:] == ["--flag", "x"]
assert cmd[:5] == ["/usr/bin/uv", "run", "--project", str(tmp_path), "--no-dev"]
@@ -36,7 +36,7 @@ def test_python_runner_falls_back_to_path_without_source() -> None:
"""No resolvable source → PATH lookup of the script (no uv run)."""
run = RunPython(launcher="python", program="my-svc")
with patch(
"castle_core.deploy.shutil.which", return_value="/home/u/.local/bin/my-svc"
"wildpc_core.deploy.shutil.which", return_value="/home/u/.local/bin/my-svc"
):
cmd = _build_run_cmd("my-svc", run, {}, [], source_dir=None)
assert cmd == ["/home/u/.local/bin/my-svc"]
@@ -46,7 +46,7 @@ def test_python_runner_warns_when_unresolvable() -> None:
"""No source and not on PATH → a warning, bare program name as last resort."""
run = RunPython(launcher="python", program="my-svc")
messages: list[str] = []
with patch("castle_core.deploy.shutil.which", return_value=None):
with patch("wildpc_core.deploy.shutil.which", return_value=None):
cmd = _build_run_cmd("my-svc", run, {}, messages, source_dir=None)
assert cmd == ["my-svc"]
assert any("my-svc" in m for m in messages)
@@ -55,7 +55,7 @@ def test_python_runner_warns_when_unresolvable() -> None:
def test_node_runner_bakes_source_dir(tmp_path: Path) -> None:
"""A node service runs the script in its source dir via `--dir` (no unit cwd)."""
run = RunNode(launcher="node", script="gateway:watch:raw", package_manager="pnpm")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/pnpm"):
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/pnpm"):
cmd = _build_run_cmd("oc", run, {}, [], source_dir=tmp_path)
assert cmd == ["/usr/bin/pnpm", "--dir", str(tmp_path), "run", "gateway:watch:raw"]
@@ -64,7 +64,7 @@ def test_node_runner_appends_args(tmp_path: Path) -> None:
run = RunNode(
launcher="node", script="start", package_manager="pnpm", args=["--port", "18789"]
)
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/pnpm"):
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/pnpm"):
cmd = _build_run_cmd("oc", run, {}, [], source_dir=tmp_path)
assert cmd == [
"/usr/bin/pnpm",
@@ -80,7 +80,7 @@ def test_node_runner_appends_args(tmp_path: Path) -> None:
def test_node_runner_without_source_omits_dir() -> None:
"""No resolvable source → bare `pnpm run` (package manager still PATH-resolved)."""
run = RunNode(launcher="node", script="start", package_manager="pnpm")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/pnpm"):
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/pnpm"):
cmd = _build_run_cmd("oc", run, {}, [], source_dir=None)
assert cmd == ["/usr/bin/pnpm", "run", "start"]
@@ -88,8 +88,8 @@ def test_node_runner_without_source_omits_dir() -> None:
def test_container_secrets_use_env_file_not_argv() -> None:
"""Secrets go through --env-file; plain vars stay as -e; no secret in argv."""
run = RunContainer(launcher="container", image="img:latest", env={"PLAIN": "1"})
env_file = Path("/home/u/.castle/secrets/env/castle-svc.service.env")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
env_file = Path("/home/u/.wildpc/secrets/env/wildpc-svc.service.env")
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("svc", run, {"PORT": "9001"}, [], secret_env_file=env_file)
joined = " ".join(cmd)
assert "--env-file" in cmd
@@ -102,7 +102,7 @@ def test_container_secrets_use_env_file_not_argv() -> None:
def test_container_without_secrets_has_no_env_file() -> None:
run = RunContainer(launcher="container", image="img:latest")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("svc", run, {}, [], secret_env_file=None)
assert "--env-file" not in cmd
@@ -120,42 +120,42 @@ def test_container_placeholders_expand_in_run_fields() -> None:
ph = {
"name": "svc",
"port": "5432",
"data_dir": "/data/castle/svc",
"tls_dir": "/data/castle/svc/tls",
"data_dir": "/data/wildpc/svc",
"tls_dir": "/data/wildpc/svc/tls",
}
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("svc", run, {}, [], placeholders=ph)
joined = " ".join(cmd)
assert "DATA=/data/castle/svc/x" in joined # env expanded
assert "/data/castle/svc/tls:/tls:ro" in joined # volume expanded
assert "DATA=/data/wildpc/svc/x" in joined # env expanded
assert "/data/wildpc/svc/tls:/tls:ro" in joined # volume expanded
assert "svc:5432" in cmd # arg expanded
def test_container_double_dollar_escapes_placeholder() -> None:
"""$${key} passes a literal ${key} through to the container's own shell/env
instead of castle expanding it (docker-compose-style escape)."""
instead of wildpc expanding it (docker-compose-style escape)."""
run = RunContainer(
launcher="container",
image="img:latest",
args=["sh", "-c", "exec myd --advertise $${name}:${port}"],
)
ph = {"name": "svc", "port": "5432"}
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("svc", run, {}, [], placeholders=ph)
# castle expands ${port} but leaves $${name} as a literal ${name} for the shell
# wildpc expands ${port} but leaves $${name} as a literal ${name} for the shell
assert "exec myd --advertise ${name}:5432" in cmd
def test_compose_runner_up_with_resolved_file(tmp_path: Path) -> None:
"""A compose service runs `docker compose -p <project> -f <abs-file> up`."""
run = RunCompose(launcher="compose", file="docker-compose.yml")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("supabase", run, {}, [], source_dir=tmp_path)
assert cmd == [
"/usr/bin/docker",
"compose",
"-p",
"castle-supabase",
"wildpc-supabase",
"-f",
str(tmp_path / "docker-compose.yml"),
"up",
@@ -165,8 +165,8 @@ def test_compose_runner_up_with_resolved_file(tmp_path: Path) -> None:
def test_compose_runner_secrets_never_hit_argv(tmp_path: Path) -> None:
"""Compose reads env from the process (systemd), not --env-file/-e — argv is clean."""
run = RunCompose(launcher="compose", file="docker-compose.yml")
env_file = Path("/home/u/.castle/secrets/env/castle-supabase.service.env")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
env_file = Path("/home/u/.wildpc/secrets/env/wildpc-supabase.service.env")
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd(
"supabase", run, {"PORT": "8000"}, [], source_dir=tmp_path,
secret_env_file=env_file,
@@ -179,7 +179,7 @@ def test_compose_runner_secrets_never_hit_argv(tmp_path: Path) -> None:
def test_compose_project_name_override(tmp_path: Path) -> None:
run = RunCompose(launcher="compose", file="stack.yml", project_name="myproj")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("s", run, {}, [], source_dir=tmp_path)
assert cmd[:6] == [
"/usr/bin/docker", "compose", "-p", "myproj", "-f", str(tmp_path / "stack.yml"),
@@ -189,13 +189,13 @@ def test_compose_project_name_override(tmp_path: Path) -> None:
def test_compose_stop_cmd_is_down(tmp_path: Path) -> None:
"""The teardown command mirrors up but ends in `down` (same project + file)."""
run = RunCompose(launcher="compose", file="docker-compose.yml")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
with patch("wildpc_core.deploy.shutil.which", return_value="/usr/bin/docker"):
stop = _build_stop_cmd("supabase", run, tmp_path)
assert stop == [
"/usr/bin/docker",
"compose",
"-p",
"castle-supabase",
"wildpc-supabase",
"-f",
str(tmp_path / "docker-compose.yml"),
"down",

View File

@@ -7,8 +7,8 @@ from pathlib import Path
import pytest
import castle_core.deploy as deploy
from castle_core.registry import (
import wildpc_core.deploy as deploy
from wildpc_core.registry import (
Deployment,
NodeConfig,
NodeRegistry,
@@ -23,7 +23,10 @@ def secret_env_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
d = tmp_path / "secrets" / "env"
monkeypatch.setattr(deploy, "SECRET_ENV_DIR", d)
monkeypatch.setattr(
deploy, "secret_env_path", lambda name: d / f"castle-{name}.service.env"
deploy,
"secret_env_path",
lambda name, kind="service": d
/ f"wildpc-{name}{'-job' if kind == 'job' else ''}.service.env",
)
return d

View File

@@ -1,6 +1,6 @@
"""Tests for the apply-time unresolved-secret gate.
`castle apply` must refuse to converge a deployment whose ``${secret:NAME}`` env
`wildpc apply` must refuse to converge a deployment whose ``${secret:NAME}`` env
references the active backend can't resolve — otherwise the value silently
degrades to a ``<MISSING_SECRET:NAME>`` placeholder and the service starts with a
bogus credential (the immich-DB-password-to-the-wrong-backend failure).
@@ -13,7 +13,7 @@ from types import SimpleNamespace
import pytest
from castle_core.deploy import _unresolved_secrets
from wildpc_core.deploy import _unresolved_secrets
def _spec(env: dict[str, str], enabled: bool = True) -> SimpleNamespace:
@@ -26,8 +26,8 @@ def file_backend(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
secrets = tmp_path / "secrets"
secrets.mkdir()
(secrets / "PRESENT").write_text("value\n")
monkeypatch.setenv("CASTLE_SECRET_BACKEND", "file")
monkeypatch.setattr("castle_core.config.SECRETS_DIR", secrets)
monkeypatch.setenv("WILDPC_SECRET_BACKEND", "file")
monkeypatch.setattr("wildpc_core.config.SECRETS_DIR", secrets)
return secrets

View File

@@ -2,8 +2,8 @@
from __future__ import annotations
import castle_core.generators.dns as dns
from castle_core.generators.dns import _zone_for, reconcile_public_dns
import wildpc_core.generators.dns as dns
from wildpc_core.generators.dns import _zone_for, reconcile_public_dns
TID = "tid-abc"
TARGET = f"{TID}.cfargotunnel.com"
@@ -18,7 +18,7 @@ class _FakeCloudflare:
"""A minimal fake of the Cloudflare API used by reconcile_public_dns.
``records`` maps zone_id -> {name: (record_id, content)}. Records POST/DELETE
calls so tests can assert exactly what castle created/removed.
calls so tests can assert exactly what wildpc created/removed.
"""
def __init__(self, records: dict[str, dict[str, tuple[str, str]]]):
@@ -75,11 +75,11 @@ def test_creates_apex_and_subdomain_in_correct_zones(monkeypatch) -> None:
def test_deletes_managed_cname_no_longer_desired(monkeypatch) -> None:
# z_payne has a stale castle-managed CNAME + a hand-managed one pointing elsewhere.
# z_payne has a stale wildpc-managed CNAME + a hand-managed one pointing elsewhere.
fake = _FakeCloudflare(records={
"z_payne": {
"payne.io": ("r1", TARGET), # castle-managed, still desired
"old.payne.io": ("r2", TARGET), # castle-managed, now stale → delete
"payne.io": ("r1", TARGET), # wildpc-managed, still desired
"old.payne.io": ("r2", TARGET), # wildpc-managed, now stale → delete
"keep.payne.io": ("r3", "other.example.com"), # NOT ours → never touched
},
"z_ex": {},

View File

@@ -5,8 +5,8 @@ from __future__ import annotations
from pathlib import Path
import yaml
from castle_core.config import load_config
from castle_core.registry import (
from wildpc_core.config import load_config
from wildpc_core.registry import (
NodeConfig,
NodeRegistry,
load_registry,
@@ -18,7 +18,7 @@ def _write_min_config(root: Path, role: str | None) -> None:
data: dict = {"gateway": {"port": 18000}}
if role is not None:
data["role"] = role
(root / "castle.yaml").write_text(yaml.safe_dump(data))
(root / "wildpc.yaml").write_text(yaml.safe_dump(data))
def test_role_defaults_to_follower(tmp_path: Path) -> None:
@@ -32,12 +32,12 @@ def test_role_loaded_from_yaml(tmp_path: Path) -> None:
def test_save_config_round_trips_role_and_secrets(tmp_path: Path) -> None:
"""save_config rewrites castle.yaml from scratch — it must re-emit `role` and
"""save_config rewrites wildpc.yaml from scratch — it must re-emit `role` and
preserve the `secrets:` block, or a save reverts the node to a file-backend
follower (the regression this guards)."""
from castle_core.config import load_config, save_config
from wildpc_core.config import load_config, save_config
(tmp_path / "castle.yaml").write_text(
(tmp_path / "wildpc.yaml").write_text(
yaml.safe_dump(
{
"gateway": {"port": 18000},
@@ -47,57 +47,57 @@ def test_save_config_round_trips_role_and_secrets(tmp_path: Path) -> None:
)
)
save_config(load_config(tmp_path))
reloaded = yaml.safe_load((tmp_path / "castle.yaml").read_text())
reloaded = yaml.safe_load((tmp_path / "wildpc.yaml").read_text())
assert reloaded.get("role") == "authority"
assert reloaded.get("secrets", {}).get("backend") == "openbao"
def test_save_config_preserves_arbitrary_unmanaged_global(tmp_path: Path) -> None:
"""Any top-level key save_config doesn't model must survive a rewrite."""
from castle_core.config import load_config, save_config
from wildpc_core.config import load_config, save_config
(tmp_path / "castle.yaml").write_text(
(tmp_path / "wildpc.yaml").write_text(
yaml.safe_dump(
{"gateway": {"port": 18000}, "role": "authority", "future_thing": {"x": 1}}
)
)
save_config(load_config(tmp_path))
reloaded = yaml.safe_load((tmp_path / "castle.yaml").read_text())
reloaded = yaml.safe_load((tmp_path / "wildpc.yaml").read_text())
assert reloaded.get("future_thing") == {"x": 1}
assert reloaded.get("role") == "authority"
def test_write_deployment_file_leaves_globals_untouched(castle_root: Path) -> None:
"""A scoped deployment write must not rewrite castle.yaml globals (the PATCH
def test_write_deployment_file_leaves_globals_untouched(wildpc_root: Path) -> None:
"""A scoped deployment write must not rewrite wildpc.yaml globals (the PATCH
guarantee that stops a deployment edit from dropping role/secrets)."""
from castle_core.config import load_config, write_deployment_file
from wildpc_core.config import load_config, write_deployment_file
cy = castle_root / "castle.yaml"
cy = wildpc_root / "wildpc.yaml"
data = yaml.safe_load(cy.read_text())
data["role"] = "authority"
data["secrets"] = {"backend": "openbao"}
cy.write_text(yaml.safe_dump(data))
before = cy.read_text()
config = load_config(castle_root)
config = load_config(wildpc_root)
kind, name, _dep = next(iter(config.all_deployments()))
write_deployment_file(config, kind, name)
assert cy.read_text() == before # globals byte-identical — nothing touched them
def test_write_program_file_leaves_globals_untouched(castle_root: Path) -> None:
"""Scoped program write must not rewrite castle.yaml globals either."""
from castle_core.config import load_config, write_program_file
def test_write_program_file_leaves_globals_untouched(wildpc_root: Path) -> None:
"""Scoped program write must not rewrite wildpc.yaml globals either."""
from wildpc_core.config import load_config, write_program_file
cy = castle_root / "castle.yaml"
cy = wildpc_root / "wildpc.yaml"
data = yaml.safe_load(cy.read_text())
data["role"] = "authority"
data["secrets"] = {"backend": "openbao"}
cy.write_text(yaml.safe_dump(data))
before = cy.read_text()
config = load_config(castle_root)
config = load_config(wildpc_root)
name = next(iter(config.programs))
write_program_file(config, name)

View File

@@ -1,4 +1,4 @@
"""Tests for git working-copy status/sync (core/src/castle_core/git.py)."""
"""Tests for git working-copy status/sync (core/src/wildpc_core/git.py)."""
from __future__ import annotations
@@ -7,7 +7,7 @@ from pathlib import Path
import pytest
from castle_core import git as G
from wildpc_core import git as G
# Identity so commits succeed without touching the user's global git config.
_ENV = {

View File

@@ -9,9 +9,9 @@ from pathlib import Path
import pytest
import yaml
from castle_core.config import load_config, save_config
from castle_core.deploy import _build_deployed, _desired_unit_files
from castle_core.registry import NodeConfig, NodeRegistry
from wildpc_core.config import load_config, save_config
from wildpc_core.deploy import _build_deployed, _desired_unit_files
from wildpc_core.registry import NodeConfig, NodeRegistry
def _write(root: Path, store: str, name: str, spec: dict) -> None:
@@ -21,7 +21,7 @@ def _write(root: Path, store: str, name: str, spec: dict) -> None:
def _trio(root: Path) -> None:
(root / "castle.yaml").write_text(yaml.dump({"gateway": {"port": 9000}}))
(root / "wildpc.yaml").write_text(yaml.dump({"gateway": {"port": 9000}}))
_write(root, "services", "backup", {
"manager": "systemd", "run": {"launcher": "python", "program": "backup"},
})
@@ -67,14 +67,14 @@ def test_service_and_job_get_distinct_units(tmp_path: Path) -> None:
dep.name = name
registry.put(dep)
units = _desired_unit_files(registry)
# service keeps castle-<name>.service; job carries the -job marker; tool has none.
assert "castle-backup.service" in units
assert "castle-backup-job.service" in units
assert "castle-backup-job.timer" in units
# service keeps wildpc-<name>.service; job carries the -job marker; tool has none.
assert "wildpc-backup.service" in units
assert "wildpc-backup-job.service" in units
assert "wildpc-backup-job.timer" in units
def test_service_and_static_cannot_share_a_subdomain(tmp_path: Path) -> None:
(tmp_path / "castle.yaml").write_text(yaml.dump({"gateway": {"port": 9000}}))
(tmp_path / "wildpc.yaml").write_text(yaml.dump({"gateway": {"port": 9000}}))
_write(tmp_path, "services", "app", {
"manager": "systemd", "run": {"launcher": "python", "program": "app"},
"reach": "internal", "expose": {"http": {"internal": {"port": 9001}}},

View File

@@ -5,63 +5,63 @@ from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from castle_core import lifecycle
from castle_core.config import load_config
from wildpc_core import lifecycle
from wildpc_core.config import load_config
class TestIsActive:
def test_service_uses_systemctl(self, castle_root: Path) -> None:
config = load_config(castle_root)
def test_service_uses_systemctl(self, wildpc_root: Path) -> None:
config = load_config(wildpc_root)
with patch.object(lifecycle, "_systemctl_active", return_value=True) as mock:
assert lifecycle.is_active("test-svc", "service", config) is True
mock.assert_called_once_with("castle-test-svc.service")
mock.assert_called_once_with("wildpc-test-svc.service")
def test_job_uses_timer(self, castle_root: Path) -> None:
config = load_config(castle_root)
def test_job_uses_timer(self, wildpc_root: Path) -> None:
config = load_config(wildpc_root)
with patch.object(lifecycle, "_systemctl_active", return_value=True) as mock:
assert lifecycle.is_active("test-job", "job", config) is True
mock.assert_called_once_with("castle-test-job-job.timer")
mock.assert_called_once_with("wildpc-test-job-job.timer")
def test_tool_checks_path(self, castle_root: Path) -> None:
config = load_config(castle_root)
def test_tool_checks_path(self, wildpc_root: Path) -> None:
config = load_config(wildpc_root)
# give the tool a source so the tool branch is reachable
config.programs["test-tool"].source = "/tmp/test-tool"
with patch.object(lifecycle, "_on_path", return_value=True) as mock:
assert lifecycle.is_active("test-tool", "tool", config) is True
mock.assert_called_once_with("test-tool")
def test_unknown_is_inactive(self, castle_root: Path) -> None:
config = load_config(castle_root)
def test_unknown_is_inactive(self, wildpc_root: Path) -> None:
config = load_config(wildpc_root)
assert lifecycle.is_active("does-not-exist", "service", config) is False
def test_path_deployment_checks_path(self, castle_root: Path) -> None:
def test_path_deployment_checks_path(self, wildpc_root: Path) -> None:
# A `manager: path` deployment (a tool) is active when on PATH.
from castle_core.manifest import PathDeployment, ProgramSpec
from wildpc_core.manifest import PathDeployment, ProgramSpec
config = load_config(castle_root)
config = load_config(wildpc_root)
config.programs["mytool"] = ProgramSpec(id="mytool", source="/tmp/mytool")
config.tools["mytool"] = PathDeployment(manager="path", program="mytool")
with patch.object(lifecycle, "_on_path", return_value=True) as mock:
assert lifecycle.is_active("mytool", "tool", config) is True
mock.assert_called_once_with("mytool")
def test_remote_deployment_is_active(self, castle_root: Path) -> None:
def test_remote_deployment_is_active(self, wildpc_root: Path) -> None:
# A remote deployment has no local process; the manager is `none` → available.
from castle_core.manifest import RemoteDeployment
from wildpc_core.manifest import RemoteDeployment
config = load_config(castle_root)
config = load_config(wildpc_root)
config.references["ext"] = RemoteDeployment(
manager="none", program="ext", base_url="http://x"
)
assert lifecycle.is_active("ext", "reference", config) is True
def test_static_deployment_active_when_dist_built(
self, castle_root: Path, tmp_path: Path
self, wildpc_root: Path, tmp_path: Path
) -> None:
# A static (caddy) deployment is active once its served dir exists.
from castle_core.manifest import CaddyDeployment, ProgramSpec
from wildpc_core.manifest import CaddyDeployment, ProgramSpec
config = load_config(castle_root)
config = load_config(wildpc_root)
repo = tmp_path / "fe"
config.programs["fe"] = ProgramSpec(id="fe", source=str(repo))
config.statics["fe"] = CaddyDeployment(

View File

@@ -1,9 +1,9 @@
"""Tests for castle manifest models."""
"""Tests for wildpc manifest models."""
from __future__ import annotations
import pytest
from castle_core.manifest import (
from wildpc_core.manifest import (
Reach,
BuildSpec,
CaddyDeployment,

View File

@@ -1,18 +1,18 @@
"""Tests for the relationship model (core/src/castle_core/relations.py)."""
"""Tests for the relationship model (core/src/wildpc_core/relations.py)."""
from __future__ import annotations
import pytest
import castle_core.config as C
from castle_core import relations as R
from castle_core.manifest import (
import wildpc_core.config as C
from wildpc_core import relations as R
from wildpc_core.manifest import (
CaddyDeployment,
ProgramSpec,
Requirement,
SystemdDeployment,
)
from castle_core.stacks import tools_for
from wildpc_core.stacks import tools_for
def _dep(program: str, requires: list | None = None) -> SystemdDeployment:
@@ -32,8 +32,8 @@ def _static(program: str) -> CaddyDeployment:
)
def _cfg(programs: dict, deployments: dict) -> C.CastleConfig:
return C.CastleConfig(
def _cfg(programs: dict, deployments: dict) -> C.WildpcConfig:
return C.WildpcConfig(
root=None,
gateway=C.GatewayConfig(port=9000),
repo=None,
@@ -141,7 +141,7 @@ def test_stack_tool_missing_from_service_path_is_unmet(
) -> None:
"""The drift case: uv resolves in the caller's shell PATH but NOT on the
service's curated runtime PATH → unmet *for the service*, even though a bare
`which` (what `castle tool list` uses) would report it present."""
`which` (what `wildpc tool list` uses) would report it present."""
prog = ProgramSpec(id="svc", stack="python-fastapi")
cfg = _cfg({"svc": prog}, {"svc": _dep("svc")})
shell_only = "/home/me/.shell-tools" # a dir the runtime PATH never includes

View File

@@ -4,8 +4,8 @@ from __future__ import annotations
from pathlib import Path
from castle_core.manifest import ProgramSpec
from castle_core.stacks import (
from wildpc_core.manifest import ProgramSpec
from wildpc_core.stacks import (
_declared_commands,
_migration_version,
available_actions,

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from pathlib import Path
from castle_core.secret_backends import (
from wildpc_core.secret_backends import (
FileSecretBackend,
OpenBaoBackend,
build_backend,
@@ -34,37 +34,37 @@ def test_file_backend_write_read_list_delete(tmp_path: Path) -> None:
def test_build_backend_defaults_to_file(tmp_path: Path, monkeypatch) -> None:
monkeypatch.delenv("CASTLE_SECRET_BACKEND", raising=False)
monkeypatch.delenv("WILDPC_SECRET_BACKEND", raising=False)
assert isinstance(build_backend(tmp_path), FileSecretBackend)
def test_build_backend_openbao_via_env(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setenv("CASTLE_SECRET_BACKEND", "openbao")
monkeypatch.setenv("WILDPC_SECRET_BACKEND", "openbao")
assert isinstance(build_backend(tmp_path), OpenBaoBackend)
def test_build_backend_openbao_via_settings(tmp_path: Path, monkeypatch) -> None:
"""The castle.yaml `secrets:` block selects the backend (env still overrides)."""
monkeypatch.delenv("CASTLE_SECRET_BACKEND", raising=False)
settings = {"backend": "openbao", "addr": "https://vault:8200", "mount": "castle"}
"""The wildpc.yaml `secrets:` block selects the backend (env still overrides)."""
monkeypatch.delenv("WILDPC_SECRET_BACKEND", raising=False)
settings = {"backend": "openbao", "addr": "https://vault:8200", "mount": "wildpc"}
assert isinstance(build_backend(tmp_path, settings), OpenBaoBackend)
def test_openbao_unreachable_returns_none_no_fallback(tmp_path: Path) -> None:
"""No file fallback: an unreachable vault returns None even if a file exists."""
(tmp_path / "ONLY_IN_FILE").write_text("from-file")
backend = OpenBaoBackend(addr="http://127.0.0.1:1", token="dummy", mount="castle")
backend = OpenBaoBackend(addr="http://127.0.0.1:1", token="dummy", mount="wildpc")
assert backend.read("ONLY_IN_FILE") is None
assert backend.read("NOT_ANYWHERE") is None
def test_openbao_empty_token_returns_none(tmp_path: Path) -> None:
backend = OpenBaoBackend(addr="http://127.0.0.1:8200", token="", mount="castle")
backend = OpenBaoBackend(addr="http://127.0.0.1:8200", token="", mount="wildpc")
assert backend.read("K") is None
def test_openbao_node_prefix_from_settings(tmp_path: Path, monkeypatch) -> None:
monkeypatch.delenv("CASTLE_SECRET_BACKEND", raising=False)
monkeypatch.delenv("WILDPC_SECRET_BACKEND", raising=False)
backend = build_backend(
tmp_path,
{"backend": "openbao", "addr": "http://x", "node_prefix": "nodes/primer"},

View File

@@ -4,23 +4,23 @@ from __future__ import annotations
from pathlib import Path
from castle_core.generators.systemd import (
from wildpc_core.generators.systemd import (
generate_timer,
generate_unit_from_deployed,
secret_env_path,
unit_env_file,
unit_name,
)
from castle_core.registry import Deployment
from wildpc_core.registry import Deployment
class TestUnitName:
"""Tests for systemd unit naming."""
def test_unit_name_format(self) -> None:
"""Unit names follow castle-<name>.service pattern."""
assert unit_name("central-context") == "castle-central-context.service"
assert unit_name("my-svc") == "castle-my-svc.service"
"""Unit names follow wildpc-<name>.service pattern."""
assert unit_name("central-context") == "wildpc-central-context.service"
assert unit_name("my-svc") == "wildpc-my-svc.service"
class TestUnitFromDeployed:
@@ -35,7 +35,7 @@ class TestUnitFromDeployed:
description="Test service",
)
unit = generate_unit_from_deployed("test-svc", deployed)
assert "Description=Castle: Test service" in unit
assert "Description=Wild PC: Test service" in unit
def test_no_working_directory(self) -> None:
"""Unit file has no WorkingDirectory (source/runtime separation)."""
@@ -53,14 +53,14 @@ class TestUnitFromDeployed:
deployed = Deployment(
manager="systemd", launcher="python",
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
env={"TEST_SVC_DATA_DIR": "/home/user/.castle/data/test-svc"},
env={"TEST_SVC_DATA_DIR": "/home/user/.wildpc/data/test-svc"},
description="Test service",
)
unit = generate_unit_from_deployed("test-svc", deployed)
assert "Environment=TEST_SVC_DATA_DIR=/home/user/.castle/data/test-svc" in unit
assert "Environment=TEST_SVC_DATA_DIR=/home/user/.wildpc/data/test-svc" in unit
def test_default_path_emitted_when_absent(self) -> None:
"""Castle supplies a default PATH when the service doesn't pin one."""
"""Wild PC supplies a default PATH when the service doesn't pin one."""
deployed = Deployment(
manager="systemd", launcher="python",
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
@@ -99,7 +99,7 @@ class TestUnitFromDeployed:
assert unit.count("PATH=") == 1
def test_explicit_path_overrides_default(self) -> None:
"""A PATH pinned in defaults.env wins — Castle does not append its own,
"""A PATH pinned in defaults.env wins — Wild PC does not append its own,
which would clobber it under systemd's last-assignment-wins rule."""
deployed = Deployment(
manager="systemd", launcher="python",
@@ -140,15 +140,15 @@ class TestUnitFromDeployed:
run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"],
env={
"MY_SVC_PORT": "9001",
"MY_SVC_DATA_DIR": "/home/user/.castle/data/my-svc",
"MY_SVC_DATA_DIR": "/home/user/.wildpc/data/my-svc",
},
description="My service",
)
unit = generate_unit_from_deployed("my-svc", deployed)
assert "Description=Castle: My service" in unit
assert "Description=Wild PC: My service" in unit
assert "ExecStart=/home/user/.local/bin/uv run my-svc" in unit
assert "Environment=MY_SVC_PORT=9001" in unit
assert "Environment=MY_SVC_DATA_DIR=/home/user/.castle/data/my-svc" in unit
assert "Environment=MY_SVC_DATA_DIR=/home/user/.wildpc/data/my-svc" in unit
assert "WorkingDirectory" not in unit
assert "Restart=on-failure" in unit
@@ -170,7 +170,7 @@ class TestUnitFromDeployed:
deployed = Deployment(
manager="systemd", launcher="python",
run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"],
env={"DATA_DIR": "/home/user/.castle/data/my-svc"},
env={"DATA_DIR": "/home/user/.wildpc/data/my-svc"},
description="Test",
)
unit = generate_unit_from_deployed("my-svc", deployed)
@@ -180,12 +180,12 @@ class TestUnitFromDeployed:
"""A compose deployment's stop_cmd becomes ExecStop= (clean teardown)."""
deployed = Deployment(
manager="systemd", launcher="compose",
run_cmd=["/usr/bin/docker", "compose", "-p", "castle-x", "-f", "c.yml", "up"],
stop_cmd=["/usr/bin/docker", "compose", "-p", "castle-x", "-f", "c.yml", "down"],
run_cmd=["/usr/bin/docker", "compose", "-p", "wildpc-x", "-f", "c.yml", "up"],
stop_cmd=["/usr/bin/docker", "compose", "-p", "wildpc-x", "-f", "c.yml", "down"],
description="Stack",
)
unit = generate_unit_from_deployed("x", deployed)
assert "ExecStop=/usr/bin/docker compose -p castle-x -f c.yml down" in unit
assert "ExecStop=/usr/bin/docker compose -p wildpc-x -f c.yml down" in unit
def test_no_exec_stop_without_stop_cmd(self) -> None:
"""Runners without a stop_cmd rely on SIGTERM — no ExecStop line."""
@@ -206,7 +206,7 @@ class TestSecretEnvFile:
env={"PORT": "9001"},
secret_env_keys=["API_KEY"],
)
path = Path("/home/u/.castle/secrets/env/castle-my-svc.service.env")
path = Path("/home/u/.wildpc/secrets/env/wildpc-my-svc.service.env")
unit = generate_unit_from_deployed("my-svc", deployed, env_file=path)
assert f"EnvironmentFile={path}" in unit
# fail-loud: no '-' prefix
@@ -220,7 +220,7 @@ class TestSecretEnvFile:
schedule="0 2 * * *",
secret_env_keys=["TOKEN"],
)
path = Path("/home/u/.castle/secrets/env/castle-my-job.service.env")
path = Path("/home/u/.wildpc/secrets/env/wildpc-my-job.service.env")
unit = generate_unit_from_deployed("my-job", deployed, env_file=path)
assert "Type=oneshot" in unit
assert f"EnvironmentFile={path}" in unit
@@ -266,7 +266,7 @@ class TestGenerateTimer:
def test_daily_timer(self) -> None:
"""Daily cron produces OnCalendar timer."""
timer = generate_timer("my-job", schedule="0 2 * * *", description="Nightly")
assert "Description=Castle timer: Nightly" in timer
assert "Description=Wild PC timer: Nightly" in timer
assert "OnCalendar=*-*-* 02:00:00" in timer
assert "WantedBy=timers.target" in timer
@@ -279,4 +279,4 @@ class TestGenerateTimer:
def test_fallback_description(self) -> None:
"""Timer uses name when no description given."""
timer = generate_timer("my-job", schedule="0 0 * * *")
assert "Description=Castle timer: my-job" in timer
assert "Description=Wild PC timer: my-job" in timer

View File

@@ -1,4 +1,4 @@
"""Tests for castle-managed TLS material (core/src/castle_core/tls.py)."""
"""Tests for wildpc-managed TLS material (core/src/wildpc_core/tls.py)."""
from __future__ import annotations
@@ -6,9 +6,9 @@ from pathlib import Path
import pytest
import castle_core.config as C
import castle_core.tls as T
from castle_core.manifest import SystemdDeployment
import wildpc_core.config as C
import wildpc_core.tls as T
from wildpc_core.manifest import SystemdDeployment
def _write_wildcard(xdg: Path, domain: str, tag: str, acme_dir: str) -> None:
@@ -34,7 +34,7 @@ def tls_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
def _cfg(C, domain, dep, data_dir):
return C.CastleConfig(
return C.WildpcConfig(
root=None,
gateway=C.GatewayConfig(port=9000, domain=domain),
repo=None,

View File

@@ -1,4 +1,4 @@
"""Tests for castle_core.tool_schema — deriving neutral tool-call cores from --help."""
"""Tests for wildpc_core.tool_schema — deriving neutral tool-call cores from --help."""
from __future__ import annotations
@@ -6,7 +6,7 @@ from types import SimpleNamespace
import pytest
from castle_core.tool_schema import (
from wildpc_core.tool_schema import (
ToolSchemaError,
_command_core,
_extract_subcommands,
@@ -115,7 +115,7 @@ class TestDerive:
class TestLLMAssistHelpers:
"""Deterministic helpers that feed / validate the (castle-api) LLM assist."""
"""Deterministic helpers that feed / validate the (wildpc-api) LLM assist."""
def test_collect_tool_help_real_tool(self) -> None:
cfg = _fake_config({"python3": SimpleNamespace(source=None)})

View File

@@ -6,8 +6,8 @@ from pathlib import Path
import pytest
from castle_core import toolchains
from castle_core.toolchains import (
from wildpc_core import toolchains
from wildpc_core.toolchains import (
ToolchainError,
read_node_pin,
resolve_node_bin,
@@ -26,7 +26,7 @@ def _install(root: Path, *versions: str) -> None:
def nvm(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
root = tmp_path / "nvm"
root.mkdir()
monkeypatch.setenv("CASTLE_NODE_VERSIONS_DIR", str(root))
monkeypatch.setenv("WILDPC_NODE_VERSIONS_DIR", str(root))
return root
@@ -117,5 +117,5 @@ class TestResolveNodeBin:
resolve_node_bin(tmp_path)
def test_default_dir_is_nvm(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CASTLE_NODE_VERSIONS_DIR", raising=False)
monkeypatch.delenv("WILDPC_NODE_VERSIONS_DIR", raising=False)
assert toolchains.node_versions_dir() == Path.home() / ".nvm" / "versions" / "node"

View File

@@ -4,11 +4,11 @@ from __future__ import annotations
import yaml
from castle_core.generators.tunnel import (
from wildpc_core.generators.tunnel import (
generate_tunnel_config,
public_hostnames,
)
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
from wildpc_core.registry import Deployment, NodeConfig, NodeRegistry
def _registry(