27ea9119a5
- config/agentd.yaml is now the agentd-01 live config (state /var/lib/plane-agentd, logs /var/log/plane-agentd); config/agentd.oregon.yaml keeps the oregon dispatcher settings for rollback (cron/agentd-tick.sh uses it). - cron/agentd-tick-lxc.sh: the LXC cron entry. - tools/win10/: win10-ps.sh / win10-put.sh (vault client dir via AGENT_ACCESS_HOME); relative helpers_dir resolves against the repo, default tools/win10. - config/protocols/gwent-board-protocol.md: copy of gwent/design/board-protocol.md used in prompts. - agentd.log_dir (default state_dir). - smoke --watch: create the item and wait for the scheduled dispatcher instead of ticking locally.
213 lines
9.4 KiB
Python
213 lines
9.4 KiB
Python
"""Load and validate the agentd YAML configuration.
|
|
|
|
The config only ever holds Vault *key names* (`token_key`). The token itself is read from the environment
|
|
variable of the same name, which `agent-access.py exec --secret-env KEY=KEY` injects into the process."""
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
from . import ROOT
|
|
|
|
SCHEMA_PATH = ROOT / "config" / "schema.json"
|
|
DEFAULT_PATHS = (ROOT / "config" / "agentd.local.yaml", ROOT / "config" / "agentd.yaml")
|
|
|
|
|
|
class ConfigError(Exception):
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------- minimal JSON-schema validator (fallback)
|
|
_TYPES = {"object": dict, "array": list, "string": str, "boolean": bool, "null": type(None)}
|
|
|
|
|
|
def _type_ok(value, name):
|
|
if name == "integer":
|
|
return isinstance(value, int) and not isinstance(value, bool)
|
|
if name == "number":
|
|
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
return isinstance(value, _TYPES[name])
|
|
|
|
|
|
def mini_validate(value, schema, root=None, path="$"):
|
|
"""Subset of draft-07: $ref (local), type, enum, required, properties, additionalProperties,
|
|
items, minItems, minProperties, minimum. Returns a list of error strings."""
|
|
root = root or schema
|
|
if "$ref" in schema:
|
|
node = root
|
|
for part in schema["$ref"].lstrip("#/").split("/"):
|
|
node = node[part]
|
|
return mini_validate(value, node, root, path)
|
|
errs = []
|
|
types = schema.get("type")
|
|
if types:
|
|
types = [types] if isinstance(types, str) else types
|
|
if not any(_type_ok(value, t) for t in types):
|
|
return ["%s: expected %s, got %s" % (path, "/".join(types), type(value).__name__)]
|
|
if "enum" in schema and value not in schema["enum"]:
|
|
errs.append("%s: %r not one of %s" % (path, value, schema["enum"]))
|
|
if "minimum" in schema and _type_ok(value, "number") and value < schema["minimum"]:
|
|
errs.append("%s: %r < minimum %s" % (path, value, schema["minimum"]))
|
|
if isinstance(value, dict):
|
|
for key in schema.get("required", []):
|
|
if key not in value:
|
|
errs.append("%s: missing required '%s'" % (path, key))
|
|
if len(value) < schema.get("minProperties", 0):
|
|
errs.append("%s: needs at least %d entries" % (path, schema["minProperties"]))
|
|
props = schema.get("properties", {})
|
|
extra = schema.get("additionalProperties", True)
|
|
for key, sub in value.items():
|
|
if key in props:
|
|
errs += mini_validate(sub, props[key], root, "%s.%s" % (path, key))
|
|
elif extra is False:
|
|
errs.append("%s: unknown key '%s'" % (path, key))
|
|
elif isinstance(extra, dict):
|
|
errs += mini_validate(sub, extra, root, "%s.%s" % (path, key))
|
|
if isinstance(value, list):
|
|
if len(value) < schema.get("minItems", 0):
|
|
errs.append("%s: needs at least %d items" % (path, schema["minItems"]))
|
|
if "items" in schema:
|
|
for i, item in enumerate(value):
|
|
errs += mini_validate(item, schema["items"], root, "%s[%d]" % (path, i))
|
|
return errs
|
|
|
|
|
|
def schema_errors(data, schema):
|
|
try:
|
|
import jsonschema
|
|
except ImportError:
|
|
return mini_validate(data, schema)
|
|
validator = jsonschema.Draft7Validator(schema)
|
|
return ["$%s: %s" % ("".join(".%s" % p if isinstance(p, str) else "[%d]" % p for p in e.absolute_path), e.message)
|
|
for e in validator.iter_errors(data)]
|
|
|
|
|
|
def semantic_errors(data):
|
|
"""Cross-references the schema cannot express."""
|
|
errs = []
|
|
terms, execs = data.get("terminals", {}), data.get("executors", {})
|
|
for tname, t in terms.items():
|
|
for ex in t.get("executors", []):
|
|
if ex not in execs:
|
|
errs.append("terminals.%s: unknown executor '%s'" % (tname, ex))
|
|
if t.get("transport") == "warpgate-ssh" and t.get("os") == "windows":
|
|
if t.get("launch") != "schtasks":
|
|
errs.append("terminals.%s: warpgate-ssh on windows needs launch=schtasks" % tname)
|
|
if t.get("target", tname) != "win10":
|
|
errs.append("terminals.%s: the Windows helper scripts only reach target 'win10' for now" % tname)
|
|
elif t.get("transport") == "warpgate-ssh":
|
|
if t.get("launch") != "nohup":
|
|
errs.append("terminals.%s: warpgate-ssh on %s needs launch=nohup" % (tname, t.get("os")))
|
|
for key in ("repo", "run_root"):
|
|
if key in t and not str(t[key]).startswith("/"):
|
|
errs.append("terminals.%s.%s: must be an absolute path on the remote (no ~)" % (tname, key))
|
|
if not data.get("vault", {}).get("config"):
|
|
errs.append("terminals.%s: remote POSIX terminals need vault.config (relay identity)" % tname)
|
|
if t.get("transport") == "local" and t.get("os") != "linux":
|
|
errs.append("terminals.%s: local transport supports only os=linux" % tname)
|
|
for ename, e in execs.items():
|
|
adapter = e.get("adapter", ename)
|
|
if adapter not in ("codex", "claude-code", "shell"):
|
|
errs.append("executors.%s: no adapter '%s' (set 'adapter')" % (ename, adapter))
|
|
cli = data.get("agentd", {}).get("cli_terminal")
|
|
if cli and cli not in terms:
|
|
errs.append("agentd.cli_terminal: unknown terminal '%s'" % cli)
|
|
for pname, p in data.get("projects", {}).items():
|
|
routing = p.get("routing", {})
|
|
for user, tname in routing.get("by_assignee", {}).items():
|
|
if tname not in terms:
|
|
errs.append("projects.%s.routing.by_assignee.%s: unknown terminal '%s'" % (pname, user, tname))
|
|
for tname, ex in routing.get("default_executor", {}).items():
|
|
if tname not in terms:
|
|
errs.append("projects.%s.routing.default_executor: unknown terminal '%s'" % (pname, tname))
|
|
elif ex not in terms[tname].get("executors", []):
|
|
errs.append("projects.%s.routing.default_executor.%s: '%s' not in that terminal's executors" % (pname, tname, ex))
|
|
for label, route in routing.get("by_label", {}).items():
|
|
if "executor" in route and route["executor"] not in execs:
|
|
errs.append("projects.%s.routing.by_label.%s: unknown executor '%s'" % (pname, label, route["executor"]))
|
|
backoff = p.get("retry", {}).get("backoff_minutes")
|
|
if backoff is not None and not backoff:
|
|
errs.append("projects.%s.retry.backoff_minutes: empty" % pname)
|
|
return errs
|
|
|
|
|
|
def validate(data, schema=None):
|
|
if schema is None:
|
|
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
errs = schema_errors(data, schema)
|
|
return errs or semantic_errors(data)
|
|
|
|
|
|
class Config:
|
|
def __init__(self, data, path=None):
|
|
self.data = data
|
|
self.path = path
|
|
self.plane = data["plane"]
|
|
self.terminals = data["terminals"]
|
|
self.executors = data["executors"]
|
|
self.projects = data["projects"]
|
|
self.agentd = data.get("agentd", {})
|
|
|
|
@property
|
|
def state_dir(self):
|
|
p = Path(os.path.expanduser(self.agentd.get("state_dir", "state")))
|
|
return p if p.is_absolute() else ROOT / p
|
|
|
|
def helpers_dir(self, terminal):
|
|
d = self.terminals[terminal].get("helpers_dir") or self.agentd.get("helpers_dir") or "tools/win10"
|
|
p = Path(os.path.expanduser(d))
|
|
return p if p.is_absolute() else ROOT / p
|
|
|
|
@property
|
|
def log_dir(self):
|
|
"""agentd.log lives here (default: state_dir)."""
|
|
d = self.agentd.get("log_dir")
|
|
if not d:
|
|
return self.state_dir
|
|
p = Path(os.path.expanduser(d))
|
|
return p if p.is_absolute() else ROOT / p
|
|
|
|
def token_env(self, terminal):
|
|
"""Environment variable that carries the terminal's Plane token (== the Vault key name)."""
|
|
return self.terminals[terminal]["token_key"]
|
|
|
|
def token(self, terminal):
|
|
return os.environ.get(self.token_env(terminal))
|
|
|
|
def secret_keys(self):
|
|
"""Every env var name that carries a secret (Plane tokens, webhook secrets): never handed to executors."""
|
|
keys = {t["token_key"] for t in self.terminals.values()}
|
|
keys |= {p["webhook_secret_key"] for p in self.projects.values() if p.get("webhook_secret_key")}
|
|
return keys
|
|
|
|
def scrubbed_env(self):
|
|
"""os.environ without the secrets above, for every child process agentd starts."""
|
|
drop = self.secret_keys()
|
|
return {k: v for k, v in os.environ.items() if k not in drop}
|
|
|
|
def secret_env_args(self):
|
|
"""`--secret-env` arguments for agent-access.py exec, one per distinct token key."""
|
|
keys = sorted({t["token_key"] for t in self.terminals.values()})
|
|
return ["--secret-env %s=%s" % (k, k) for k in keys]
|
|
|
|
def project(self, name):
|
|
if name not in self.projects:
|
|
raise ConfigError("unknown project %s (have %s)" % (name, ", ".join(self.projects)))
|
|
return self.projects[name]
|
|
|
|
|
|
def load(path=None):
|
|
if path is None:
|
|
env = os.environ.get("AGENTD_CONFIG")
|
|
candidates = [Path(env)] if env else list(DEFAULT_PATHS)
|
|
path = next((p for p in candidates if p.exists()), None)
|
|
if path is None:
|
|
raise ConfigError("no config found (tried %s); copy config/agentd.example.yaml" % ", ".join(map(str, candidates)))
|
|
path = Path(path)
|
|
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
errs = validate(data)
|
|
if errs:
|
|
raise ConfigError("%s is invalid:\n " % path + "\n ".join(errs))
|
|
return Config(data, path)
|