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:
@@ -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"]
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
"""Castle core library - manifest models, configuration, and generators."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
3
core/src/wildpc_core/__init__.py
Normal file
3
core/src/wildpc_core/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Wild PC core library - manifest models, configuration, and generators."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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():
|
||||
@@ -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."""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
@@ -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 += ["}", ""]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 ()
|
||||
|
||||
@@ -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():
|
||||
@@ -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:
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"},
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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": {},
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}}},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user