mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-12 15:03:55 +09:00
You could not install a database from ServerKit. The tree's four roots were literals, only MySQL and PostgreSQL reported real state, and no install path existed anywhere in the backend. Rather than add one, this makes a database engine an ordinary app template that declares a top-level `engine:` block — family, wire protocol, admin user, which variable holds the password, what it calls a "table". Presence of that block is the only thing that lists a template in the Databases catalog, and installing it is the template pipeline that already existed: same deployment job, same streaming console. The consequence is the point. Adding CockroachDB is adding a YAML file. So is adding something we have never heard of — author it locally or sync a template repo — and the install form still works, because it is generated from the template's own `variables[]` rather than hand-written per engine. There is no engine model, no engine table, no engine install mechanism to keep in step. `test_dropping_a_new_engine_yaml_lists_it` is the design; if it ever needs code to pass, this went wrong. Twelve engines ship: postgresql, mysql, mariadb, mongodb, redis, valkey, cockroachdb, clickhouse, influxdb, neo4j, meilisearch, couchdb. Each pins its image, binds to loopback, names a data volume, and was checked against the upstream image's actual environment contract — a wrong variable name is a container that boots with authentication silently disabled. Two safety notes worth reading. Engines bind 127.0.0.1 unless explicitly exposed, and the flag is applied after caller variables so a handcrafted payload cannot quietly publish one. And CockroachDB single-node runs `--insecure` because password auth needs a certificate-backed cluster: for engines with no credentials at all the loopback bind is the only control, so public exposure is refused outright rather than warned about. A test pins the passwordless list to exactly cockroachdb, so a future template that drops its password variable fails loudly instead of opening a hole. Also surfaced at last: `ManagedDatabase.owner_application_id` has been accepted by the create endpoints for ages with no UI to set it. Creating a database now offers "Attach to application". Deferred deliberately: new-table builder, SQL dump import, engine extensions (pgvector, TimescaleDB) — each a data-mutation surface deserving its own round. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
302 lines
12 KiB
Python
302 lines
12 KiB
Python
"""Catalog certification: prove EVERY bundled service template conforms to the
|
|
documented schema (``docs/TEMPLATE_CATALOG_SCHEMA.md``) and that the committed
|
|
``index.json`` stays in lock-step with what is actually on disk.
|
|
|
|
Unlike ``test_service_templates.py`` (which parametrizes per file and checks a
|
|
few roadmap-specific conventions), this module loads the WHOLE catalog and
|
|
asserts the universal contract in aggregate, **collecting every failure and
|
|
reporting them together** instead of stopping at the first one. The assertions
|
|
mirror the real implementation in ``TemplateService`` rather than the prose:
|
|
|
|
* parses + passes ``validate_template`` and ``validate_catalog_entry``
|
|
* a unique, slug-shaped ``id`` that matches the filename stem
|
|
* a ``name``
|
|
* at least one of ``compose`` / ``dockerfile`` / ``ports``
|
|
* every ``${VAR}`` referenced in compose / files / scripts is either declared
|
|
in ``variables``, a recognized magic variable (``${SERVICE_*}``), or a
|
|
built-in injected by the installer (``APP_NAME``)
|
|
* the committed ``index.json`` lists exactly the template ids on disk, with a
|
|
well-formed entry per template
|
|
|
|
These checks are pure: no Docker, no network, no DB. They certify only — they
|
|
do not change how any template installs.
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
|
|
import yaml
|
|
|
|
from app.services.template_service import TemplateService
|
|
|
|
TEMPLATES_DIR = TemplateService.LOCAL_TEMPLATES_DIR
|
|
INDEX_PATH = os.path.join(TEMPLATES_DIR, "index.json")
|
|
|
|
# Built-in variables the installer always injects into the substitution map
|
|
# (see ``_prepare_install_variables`` / ``install_template`` — both seed
|
|
# ``{'APP_NAME': app_name}`` before resolving declared/magic variables).
|
|
BUILTIN_VARS = {"APP_NAME"}
|
|
|
|
# Magic-variable prefixes recognized by ``TemplateService`` (single source of
|
|
# truth: the service's own table, so this test tracks the code if it grows).
|
|
MAGIC_PREFIXES = tuple(prefix for prefix, _ in TemplateService.MAGIC_PREFIXES)
|
|
|
|
# The exact ``${VAR}`` substitution pattern the installer uses
|
|
# (``TemplateService.substitute_variables``).
|
|
VAR_PATTERN = re.compile(r"\$\{([A-Z_][A-Z0-9_]*)\}")
|
|
|
|
|
|
def _template_files():
|
|
return sorted(
|
|
f for f in os.listdir(TEMPLATES_DIR) if f.endswith((".yaml", ".yml"))
|
|
)
|
|
|
|
|
|
def _load_raw(filename):
|
|
with open(os.path.join(TEMPLATES_DIR, filename), encoding="utf-8") as fh:
|
|
return yaml.safe_load(fh)
|
|
|
|
|
|
def _declared_var_names(template):
|
|
"""Names declared under ``variables`` (list OR dict form, both supported)."""
|
|
names = set()
|
|
raw = template.get("variables", [])
|
|
if isinstance(raw, list):
|
|
for var in raw:
|
|
if isinstance(var, dict) and "name" in var:
|
|
names.add(var["name"])
|
|
elif isinstance(raw, dict):
|
|
names.update(raw.keys())
|
|
return names
|
|
|
|
|
|
def _is_magic(name):
|
|
return any(name.startswith(prefix) for prefix in MAGIC_PREFIXES)
|
|
|
|
|
|
def _scan_var_refs(node, out):
|
|
"""Collect every ``${VAR}`` token under a (possibly nested) compose/files/
|
|
scripts node, mirroring how the installer walks the structure."""
|
|
if isinstance(node, str):
|
|
out.update(VAR_PATTERN.findall(node))
|
|
elif isinstance(node, dict):
|
|
for value in node.values():
|
|
_scan_var_refs(value, out)
|
|
elif isinstance(node, list):
|
|
for item in node:
|
|
_scan_var_refs(item, out)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Catalog-wide certification (one test, all failures collected)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_every_template_conforms_to_schema():
|
|
"""Load every bundled template and assert the universal contract, gathering
|
|
all violations into one report so a single run shows the full picture."""
|
|
failures = []
|
|
seen_ids = {}
|
|
|
|
files = _template_files()
|
|
assert files, "no templates found on disk"
|
|
|
|
for filename in files:
|
|
stem = filename.rsplit(".", 1)[0]
|
|
|
|
# --- parse + the loader's own validator ---------------------------
|
|
result = TemplateService.parse_template(os.path.join(TEMPLATES_DIR, filename))
|
|
if not result.get("success"):
|
|
failures.append(
|
|
f"{filename}: parse/validate_template failed: "
|
|
f"{result.get('errors') or result.get('error')}"
|
|
)
|
|
continue
|
|
template = result["template"]
|
|
|
|
# --- catalog-level validator (id slug, var types, magic tokens) ---
|
|
catalog = TemplateService.validate_catalog_entry(template)
|
|
if not catalog.get("valid"):
|
|
failures.append(
|
|
f"{filename}: validate_catalog_entry failed: {catalog.get('errors')}"
|
|
)
|
|
|
|
# --- id: present, unique, slug, matches filename stem -------------
|
|
tid = template.get("id")
|
|
if not tid:
|
|
failures.append(f"{filename}: missing 'id'")
|
|
else:
|
|
if tid != stem:
|
|
failures.append(f"{filename}: id '{tid}' != filename stem '{stem}'")
|
|
if tid in seen_ids:
|
|
failures.append(
|
|
f"{filename}: duplicate id '{tid}' (also in {seen_ids[tid]})"
|
|
)
|
|
else:
|
|
seen_ids[tid] = filename
|
|
|
|
# --- name ---------------------------------------------------------
|
|
if not template.get("name"):
|
|
failures.append(f"{filename}: missing 'name'")
|
|
|
|
# --- deployable shape depends on kind -----------------------------
|
|
# compose templates (the default) must define at least one of
|
|
# compose/dockerfile/ports; repo templates (kind: repo) instead carry a
|
|
# `repo` block with a `url` and are deployed through the wizard.
|
|
if template.get("kind") == "repo":
|
|
repo = template.get("repo")
|
|
if not isinstance(repo, dict) or not repo.get("url"):
|
|
failures.append(f"{filename}: repo template missing 'repo.url'")
|
|
elif not any(k in template for k in ("compose", "dockerfile", "ports")):
|
|
failures.append(
|
|
f"{filename}: must define at least one of compose/dockerfile/ports"
|
|
)
|
|
|
|
# --- every ${VAR} referenced resolves -----------------------------
|
|
refs = set()
|
|
_scan_var_refs(template.get("compose", {}), refs)
|
|
_scan_var_refs(template.get("files", []), refs)
|
|
_scan_var_refs(template.get("scripts", {}), refs)
|
|
|
|
declared = _declared_var_names(template)
|
|
for ref in sorted(refs):
|
|
if ref in BUILTIN_VARS or ref in declared or _is_magic(ref):
|
|
continue
|
|
failures.append(
|
|
f"{filename}: references undeclared variable '${{{ref}}}' "
|
|
f"(not declared, not a magic var, not a built-in)"
|
|
)
|
|
|
|
assert not failures, "Catalog schema violations:\n" + "\n".join(failures)
|
|
|
|
|
|
def test_index_json_matches_templates_on_disk():
|
|
"""The committed ``index.json`` must list exactly the template ids that exist
|
|
on disk (and vice-versa), each as a well-formed entry. This is the curated,
|
|
hand-shaped repo index (id/name/version/description/categories) — distinct
|
|
from ``build_repo_index()`` — so we assert the committed file directly."""
|
|
assert os.path.exists(INDEX_PATH), "index.json is missing"
|
|
with open(INDEX_PATH, encoding="utf-8") as fh:
|
|
index = json.load(fh)
|
|
|
|
disk_ids = {f.rsplit(".", 1)[0] for f in _template_files()}
|
|
index_entries = index.get("templates", [])
|
|
index_ids = {e.get("id") for e in index_entries}
|
|
|
|
failures = []
|
|
|
|
missing_from_index = sorted(disk_ids - index_ids)
|
|
if missing_from_index:
|
|
failures.append(f"on disk but NOT in index.json: {missing_from_index}")
|
|
|
|
stale_in_index = sorted(index_ids - disk_ids)
|
|
if stale_in_index:
|
|
failures.append(f"in index.json but NOT on disk: {stale_in_index}")
|
|
|
|
# No duplicate ids inside the index.
|
|
seen = set()
|
|
for entry in index_entries:
|
|
eid = entry.get("id")
|
|
if eid in seen:
|
|
failures.append(f"duplicate id in index.json: '{eid}'")
|
|
seen.add(eid)
|
|
|
|
# Each entry is well-formed (the curated lean shape).
|
|
for entry in index_entries:
|
|
for field in ("id", "name", "version", "description"):
|
|
if not entry.get(field):
|
|
failures.append(f"index entry '{entry.get('id')}' missing '{field}'")
|
|
if not isinstance(entry.get("categories", []), list):
|
|
failures.append(
|
|
f"index entry '{entry.get('id')}' has non-list 'categories'"
|
|
)
|
|
|
|
assert not failures, "index.json out of sync:\n" + "\n".join(failures)
|
|
|
|
|
|
def test_index_entry_fields_match_template_source():
|
|
"""Each index entry's name/version/description should reflect the actual
|
|
template YAML (so the curated index can't drift from the source files)."""
|
|
with open(INDEX_PATH, encoding="utf-8") as fh:
|
|
index = json.load(fh)
|
|
by_id = {e["id"]: e for e in index["templates"] if e.get("id")}
|
|
|
|
failures = []
|
|
for filename in _template_files():
|
|
template = _load_raw(filename)
|
|
tid = template.get("id")
|
|
entry = by_id.get(tid)
|
|
if entry is None:
|
|
continue # absence is already reported by the sync test
|
|
if entry.get("name") != template.get("name"):
|
|
failures.append(
|
|
f"{tid}: index name {entry.get('name')!r} != "
|
|
f"template {template.get('name')!r}"
|
|
)
|
|
if str(entry.get("version")) != str(template.get("version")):
|
|
failures.append(
|
|
f"{tid}: index version {entry.get('version')!r} != "
|
|
f"template {template.get('version')!r}"
|
|
)
|
|
|
|
assert not failures, "index.json fields drifted from templates:\n" + "\n".join(
|
|
failures
|
|
)
|
|
|
|
|
|
def test_index_carries_the_engine_block_for_engine_templates():
|
|
"""A database engine is a template with an ``engine:`` block, and the index
|
|
is what a panel syncing this bundle as a repo reads.
|
|
|
|
If the block is missing from the index, a synced CockroachDB arrives as an
|
|
ordinary app template and never appears in the Databases engine catalog --
|
|
silently, because everything still installs. This has drifted once already:
|
|
the index was regenerated by a path that dropped the block.
|
|
"""
|
|
with open(INDEX_PATH, encoding="utf-8") as fh:
|
|
index = json.load(fh)
|
|
by_id = {e["id"]: e for e in index["templates"] if e.get("id")}
|
|
|
|
failures = []
|
|
for filename in _template_files():
|
|
template = _load_raw(filename)
|
|
tid = template.get("id")
|
|
source_engine = template.get("engine")
|
|
if not isinstance(source_engine, dict):
|
|
continue
|
|
entry = by_id.get(tid)
|
|
if entry is None:
|
|
continue # absence is already reported by the sync test
|
|
if not isinstance(entry.get("engine"), dict):
|
|
failures.append(f"{tid}: declares engine: but index.json omits it")
|
|
continue
|
|
# The index carries the NORMALIZED block (the loader fills in defaults
|
|
# like bind_var), so compare against what the service computes rather
|
|
# than the raw YAML.
|
|
expected = TemplateService.engine_metadata(template)
|
|
if entry["engine"] != expected:
|
|
differing = sorted(
|
|
k for k in set(expected) | set(entry["engine"])
|
|
if expected.get(k) != entry["engine"].get(k)
|
|
)
|
|
failures.append(f"{tid}: index engine block differs on {differing}")
|
|
|
|
assert not failures, (
|
|
"index.json engine blocks drifted -- regenerate with "
|
|
"TemplateService.export_repo_index():\n" + "\n".join(failures)
|
|
)
|
|
|
|
|
|
def test_build_repo_index_covers_disk():
|
|
"""``build_repo_index()`` (the publishable generated index consumed by the
|
|
repo-sync flow) must enumerate every template currently on disk."""
|
|
generated = TemplateService.build_repo_index()
|
|
disk_ids = {f.rsplit(".", 1)[0] for f in _template_files()}
|
|
generated_ids = {t["id"] for t in generated["templates"]}
|
|
|
|
assert generated["count"] == len(_template_files())
|
|
assert generated_ids == disk_ids, (
|
|
"build_repo_index() drift -- "
|
|
f"missing: {sorted(disk_ids - generated_ids)}, "
|
|
f"extra: {sorted(generated_ids - disk_ids)}"
|
|
)
|