Files
ServerKit/backend/app/services/database_engine_service.py
Juan DenisandClaude Opus 5 5f8a3ef204 feat(recycle-bin): applications are soft-deletable, and a delete stops keeping data hostage
Plan 70. Domain went through this and TEN read paths were wrong, four with
effects outside the panel. Application has 211 call sites across 68 files and
drives more -- container lifecycles, nginx, backups, registry pulls, the OS
crontab -- so it was audited in full first (docs/plans/70_...md). 180 sites are
now query_active(); the 35 left unfiltered are each commented in place.

**The delete / purge split is what makes the tombstone honest.** delete_app used
to run compose_down(volumes=True) and rmtree(). Keeping a tombstone AND doing
that would put a Restore button next to a row whose data the delete had already
destroyed -- the one promise a recycle bin cannot break. So:

  DELETE   containers down (VOLUMES KEPT), vhost removed, firewall closed,
           cron suspended, row tombstoned. All reversible.
  RESTORE  vhost rewritten, firewall reopened, cron resumed. Containers left
           STOPPED -- "put it back" must not silently start processes or open
           ports, the same reasoning that stops domain restore re-issuing certs.
  PURGE    volumes removed, uploaded source deleted, cron detached.

register() gained on_purge for that, wired into BOTH purge paths. And
purge_expired had NO scheduler -- fine when a tombstone was just a row, not fine
when it holds data volumes and a source tree, so a daily builtin job now runs
it. Without that, "kept until purge" would have meant kept forever and delete
would have quietly stopped reclaiming disk.

A side effect worth naming: the old _remove_data_flag protection for database
engines is now the DEFAULT for every app, and the flag is carried into
?purge=true so an explicit skip-the-bin delete still honours it.

**Cron was the blocker with no mechanical answer.** before_delete never fires on
a soft delete, and clear_application is LOSSY -- it nulls the association
without recording it, so nothing could put it back. suspend_for_application
instead disables only the jobs that were CURRENTLY ENABLED and stamps each
suspended_by_app; resume re-enables exactly those. That flag is the whole point:
without it a restore switches back on jobs the user deliberately turned off.
clear_application survives as purge-only, where lossiness is correct.

**The indirect reach (audit 3.1) is the class that grep cannot find.**
ContainerSleepPolicy, BackupPolicy, DeploymentJob and WafPolicy are enumerated
on a SCHEDULE and resolve application_id afterwards, so they never appear in a
search for Application.query. Left alone, a tombstoned app's sleep policy is
swept forever and wake_app restarts the containers the delete tore down. Those
loops test Application.deleted_ids().

Deliberately NOT scoped: reservation checks (port, name, directory) -- a
tombstone must keep holding those or a new app takes them and the restore is
refused; api/git.py's webhook VISIBILITY gate, which falls through to
"panel-wide visible" so hiding a tombstone would WIDEN access; and cron
attribution, which only labels a row.

Verified: 15 new tests, the migration replayed on a fresh DB (a tombstone frees
its private_slug, two live rows still collide), and a full delete -> restore
round trip. The 20 remaining full-suite failures are the known
serverkit-wordpress-not-installed class and are identical on a clean tree.

Two traps caught by testing, both recorded in the plan: a bad import made the
ENTIRE purge cleanup silently no-op while purge() still reported success
(_run_purge_hook swallows by design, so assert on the warning, not just ok), and
private_slug had to lose unique=True at the MODEL level or a models-built
database burns the slug regardless of the migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:26:04 -04:00

322 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_active().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_active().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'
# Deliberately unfiltered: a soft-deleted app must KEEP reserving its name,
# or a new instance takes it and restoring from the recycle bin collides.
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()