mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-09 21:43:05 +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>
320 lines
14 KiB
Python
320 lines
14 KiB
Python
"""Database engines, composed from the template catalog.
|
|
|
|
There is no engine model, no engine table and no engine install mechanism. A
|
|
database engine **is** an app template that carries a top-level ``engine:``
|
|
block, and installing one **is** installing that template through the existing
|
|
deployment-job pipeline. Adding CockroachDB -- or anything else -- is dropping a
|
|
YAML file into ``backend/templates/`` or syncing a template repository. No
|
|
Python changes, ever.
|
|
|
|
This module only composes:
|
|
|
|
* :func:`engine_catalog` -- engine templates + how many instances each has
|
|
* :func:`installed_engines` -- ``Application`` rows born from an engine
|
|
template, with live container status and their engine metadata resolved
|
|
* :func:`plan_install` -- turns the Databases install form into the variables
|
|
the template pipeline already understands
|
|
|
|
Safety rules that live here (the rest are enforced by the templates themselves):
|
|
|
|
1. **Private by default.** The bind-address variable is set from the explicit
|
|
``expose_public`` flag *after* merging caller-supplied variables, so a
|
|
handcrafted ``variables`` payload cannot quietly publish an engine.
|
|
3. **Port collisions are refused** before a job is created, with the next free
|
|
port in the 409 body.
|
|
6. **Images come from the template's compose only.** ``version`` is checked
|
|
against the template's own ``engine.versions`` allow-list, so the tag can
|
|
never be attacker-chosen.
|
|
|
|
Rule 5 (never destroy the data volume implicitly) is enforced at the app-delete
|
|
route: see ``_remove_data_flag`` in ``app/api/apps.py``.
|
|
"""
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
from typing import Dict, List, Optional
|
|
|
|
from app.services.template_service import TemplateService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Which non-secret install variables are safe to echo back on a listing.
|
|
# Anything else (passwords, tokens, master keys) is never read out of the
|
|
# recorded install info.
|
|
_PUBLIC_VARIABLE_KEYS = ('BIND_ADDRESS', 'PORT', 'HTTP_PORT', 'DB_NAME')
|
|
|
|
|
|
# ── template <-> application mapping ─────────────────────────────────────────
|
|
def app_template_id(app) -> Optional[str]:
|
|
"""Which template an ``Application`` was installed from, or ``None``.
|
|
|
|
Primary source is the panel's template config (written by the install
|
|
finalizer). Falls back to the ``.serverkit-template.json`` dropped in the
|
|
app directory, so an app survives a lost/reset config file.
|
|
"""
|
|
try:
|
|
installed = TemplateService.get_config().get('installed', {}) or {}
|
|
record = installed.get(str(getattr(app, 'id', '')))
|
|
if record and record.get('template_id'):
|
|
return record['template_id']
|
|
except Exception:
|
|
pass
|
|
info = _install_info(app)
|
|
return (info or {}).get('template_id')
|
|
|
|
|
|
def _install_info(app) -> Optional[Dict]:
|
|
"""Parsed ``.serverkit-template.json`` from the app directory, or ``None``."""
|
|
root = getattr(app, 'root_path', None)
|
|
if not root:
|
|
return None
|
|
path = os.path.join(root, '.serverkit-template.json')
|
|
try:
|
|
with open(path, 'r', encoding='utf-8') as fh:
|
|
data = json.load(fh)
|
|
return data if isinstance(data, dict) else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def is_engine_app(app) -> bool:
|
|
"""True when ``app`` was installed from a template declaring ``engine:``."""
|
|
template_id = app_template_id(app)
|
|
if not template_id:
|
|
return False
|
|
result = TemplateService.get_template(template_id)
|
|
return bool(result.get('success')
|
|
and TemplateService.is_engine_template(result['template']))
|
|
|
|
|
|
# ── catalog ──────────────────────────────────────────────────────────────────
|
|
def engine_catalog() -> List[Dict]:
|
|
"""Every template carrying an ``engine:`` block, local or synced.
|
|
|
|
Enriched with ``installed_count`` / ``instances`` so the catalog drawer can
|
|
show an "Already running" section without a second round trip.
|
|
"""
|
|
from app.models import Application
|
|
|
|
counts: Dict[str, List[int]] = {}
|
|
for app in Application.query.all():
|
|
template_id = app_template_id(app)
|
|
if template_id:
|
|
counts.setdefault(template_id, []).append(app.id)
|
|
|
|
catalog = []
|
|
for entry in TemplateService.list_engine_templates():
|
|
instances = counts.get(entry['id'], [])
|
|
item = dict(entry)
|
|
item.pop('filepath', None) # a server path is not the UI's business
|
|
item['installed_count'] = len(instances)
|
|
item['instances'] = instances
|
|
catalog.append(item)
|
|
return catalog
|
|
|
|
|
|
def catalog_families(catalog: List[Dict]) -> List[str]:
|
|
"""Families actually present in the catalog, in the canonical order.
|
|
|
|
Derived from the response so the UI never hardcodes a family list.
|
|
"""
|
|
present = {(e.get('engine') or {}).get('family') for e in catalog}
|
|
return [f for f in TemplateService.ENGINE_FAMILIES if f in present]
|
|
|
|
|
|
# ── installed instances ──────────────────────────────────────────────────────
|
|
def installed_engines(live_status: bool = True) -> List[Dict]:
|
|
"""Applications installed from an engine template, with their metadata."""
|
|
from app.models import Application
|
|
|
|
results = []
|
|
for app in Application.query.order_by(Application.created_at.asc()).all():
|
|
template_id = app_template_id(app)
|
|
if not template_id:
|
|
continue
|
|
fetched = TemplateService.get_template(template_id)
|
|
if not fetched.get('success'):
|
|
continue
|
|
template = fetched['template']
|
|
engine = TemplateService.engine_metadata(template)
|
|
if not engine:
|
|
continue
|
|
results.append(_describe_instance(app, template_id, template, engine,
|
|
live_status=live_status))
|
|
return results
|
|
|
|
|
|
def _describe_instance(app, template_id, template, engine, live_status=True) -> Dict:
|
|
variables = ((_install_info(app) or {}).get('variables') or {})
|
|
bind = variables.get(engine.get('bind_var') or TemplateService.ENGINE_DEFAULT_BIND_VAR)
|
|
data = {
|
|
'app_id': app.id,
|
|
'name': app.name,
|
|
'status': app.status,
|
|
'port': app.port,
|
|
'server_id': app.server_id,
|
|
'root_path': app.root_path,
|
|
'created_at': app.created_at.isoformat() if app.created_at else None,
|
|
'template_id': template_id,
|
|
'template_name': template.get('name'),
|
|
'template_version': template.get('version'),
|
|
'icon': template.get('icon'),
|
|
'engine': engine,
|
|
# Explicitly derived, not guessed: anything other than a loopback bind
|
|
# means the port is reachable off-host.
|
|
'exposed_public': bool(bind) and bind not in ('127.0.0.1', 'localhost', '::1'),
|
|
# Never echo the admin secret; say only that one exists.
|
|
'has_secret': bool(engine.get('admin_password_var')
|
|
and variables.get(engine['admin_password_var'])),
|
|
'variables': {k: v for k, v in variables.items() if k in _PUBLIC_VARIABLE_KEYS},
|
|
}
|
|
data['container'] = _container_state(app) if live_status else None
|
|
return data
|
|
|
|
|
|
def _container_state(app) -> Optional[Dict]:
|
|
"""Live container state, or ``None`` when Docker cannot answer."""
|
|
from app.services.docker_service import DockerService
|
|
ref = getattr(app, 'container_id', None) or app.name
|
|
try:
|
|
state = DockerService.get_container_state(ref)
|
|
except Exception:
|
|
return None
|
|
if not state:
|
|
return None
|
|
return {'running': bool(state.get('running')), 'state': state.get('state')}
|
|
|
|
|
|
# ── install ──────────────────────────────────────────────────────────────────
|
|
def _err(message: str, status_code: int = 400, **extra) -> Dict:
|
|
payload = {'error': message, 'status_code': status_code}
|
|
payload.update(extra)
|
|
return payload
|
|
|
|
|
|
def _slug(value: str) -> str:
|
|
return re.sub(r'[^a-z0-9-]+', '-', (value or '').strip().lower()).strip('-')
|
|
|
|
|
|
def default_instance_name(template_id: str) -> str:
|
|
"""``postgresql``, then ``postgresql-2``… — the first name not taken."""
|
|
from app.models import Application
|
|
|
|
base = _slug(template_id) or 'engine'
|
|
taken = {a.name for a in Application.query.all()}
|
|
if base not in taken:
|
|
return base
|
|
for suffix in range(2, 100):
|
|
candidate = f'{base}-{suffix}'
|
|
if candidate not in taken:
|
|
return candidate
|
|
return f'{base}-{os.urandom(3).hex()}'
|
|
|
|
|
|
def plan_install(*, template_id, version=None, instance_name=None, port=None,
|
|
expose_public=False, initial_database=None, variables=None) -> Dict:
|
|
"""Validate the Databases install form and render it into template variables.
|
|
|
|
Returns ``{'app_name', 'variables', 'template', 'engine', 'warning'?}`` or an
|
|
error dict carrying ``status_code``. Performs no side effects -- the caller
|
|
hands the result straight to the template install pipeline.
|
|
"""
|
|
if not template_id:
|
|
return _err('template_id is required', 400)
|
|
|
|
fetched = TemplateService.get_template(template_id)
|
|
if not fetched.get('success'):
|
|
return _err(f"Template '{template_id}' not found", 404)
|
|
|
|
template = fetched['template']
|
|
engine = TemplateService.engine_metadata(template)
|
|
if not engine:
|
|
# Safety rule 6: this route installs engines, and an engine is defined
|
|
# by its template. Anything else goes through the normal template flow.
|
|
return _err(f"Template '{template_id}' is not a database engine "
|
|
"(it declares no `engine:` block)", 400)
|
|
|
|
declared = _declared_variables(template)
|
|
resolved: Dict[str, str] = dict(variables or {})
|
|
|
|
# Version -> image tag, checked against the template's own allow-list so a
|
|
# caller can never inject an arbitrary image reference.
|
|
if version not in (None, ''):
|
|
version = str(version)
|
|
if version not in engine['versions']:
|
|
return _err(f"Version '{version}' is not offered by {template.get('name')}", 400,
|
|
versions=engine['versions'])
|
|
version_var = (template.get('engine') or {}).get('version_var') or 'IMAGE_TAG'
|
|
if version_var not in declared:
|
|
return _err(f"{template.get('name')} does not support choosing a version", 400)
|
|
resolved[version_var] = version
|
|
|
|
# Instance name == app name; the pipeline's own rules apply.
|
|
app_name = _slug(instance_name) if instance_name else default_instance_name(template_id)
|
|
from app.utils.slug import validate_app_name
|
|
valid, error = validate_app_name(app_name, min_length=3)
|
|
if not valid:
|
|
return _err(error, 400)
|
|
|
|
# Safety rule 3: refuse a busy port up front and name a free one.
|
|
if port not in (None, ''):
|
|
try:
|
|
port = int(port)
|
|
except (TypeError, ValueError):
|
|
return _err('port must be an integer', 400)
|
|
if not 1 <= port <= 65535:
|
|
return _err('port must be between 1 and 65535', 400)
|
|
if not TemplateService._port_is_free(port):
|
|
return _err(f'Port {port} is already in use', 409, port=port,
|
|
suggested_port=TemplateService._find_available_port(port + 1))
|
|
port_var = engine.get('port_var') or TemplateService.ENGINE_DEFAULT_PORT_VAR
|
|
if port_var not in declared:
|
|
return _err(f"{template.get('name')} does not expose a configurable port", 400)
|
|
resolved[port_var] = str(port)
|
|
|
|
if initial_database not in (None, ''):
|
|
database_var = engine.get('database_var')
|
|
if not database_var:
|
|
return _err(f"{template.get('name')} does not support an initial database", 400)
|
|
resolved[database_var] = str(initial_database)
|
|
|
|
# Safety rule 1, applied LAST so a caller-supplied `variables` entry can
|
|
# never override the explicit private/public decision.
|
|
warning = None
|
|
if expose_public and not engine.get('admin_password_var'):
|
|
# Some engines have no credentials to expose at all -- CockroachDB's
|
|
# single-node mode runs `--insecure`. For those the loopback bind is the
|
|
# ONLY access control, so publishing one to every interface is refused
|
|
# outright rather than warned about.
|
|
return _err(f"{template.get('name')} runs without authentication, so it "
|
|
'cannot be exposed publicly. Keep it bound to localhost and '
|
|
'reach it over a tunnel, or move it to a credentialed '
|
|
'configuration first.', 400)
|
|
bind_var = engine.get('bind_var') or TemplateService.ENGINE_DEFAULT_BIND_VAR
|
|
if bind_var in declared:
|
|
resolved[bind_var] = (TemplateService.ENGINE_PUBLIC_BIND if expose_public
|
|
else TemplateService.ENGINE_PRIVATE_BIND)
|
|
elif expose_public:
|
|
return _err(f"{template.get('name')} cannot be exposed publicly "
|
|
"(its template declares no bind-address variable)", 400)
|
|
|
|
if expose_public:
|
|
warning = (f"{template.get('name')} will accept connections from any network "
|
|
'interface. Anyone who can reach this host can attempt to '
|
|
'authenticate against it — restrict it with the firewall, or '
|
|
'keep it private and reach it over a tunnel.')
|
|
|
|
return {'app_name': app_name, 'variables': resolved, 'template': template,
|
|
'engine': engine, 'warning': warning}
|
|
|
|
|
|
def _declared_variables(template: Dict) -> set:
|
|
"""Names of the variables a template declares (list or dict form)."""
|
|
raw = template.get('variables', [])
|
|
if isinstance(raw, list):
|
|
return {v.get('name') for v in raw if isinstance(v, dict) and v.get('name')}
|
|
if isinstance(raw, dict):
|
|
return set(raw.keys())
|
|
return set()
|