mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-02 18:17:11 +09:00
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>
525 lines
23 KiB
Python
525 lines
23 KiB
Python
"""Database engine extensions — installed INTO a running engine, not beside it.
|
|
|
|
An engine is an app template because an engine gets a container. An extension
|
|
gets nothing: installing pgvector is executing ``CREATE EXTENSION IF NOT EXISTS
|
|
vector;`` against a database on a PostgreSQL that is already up. So an extension
|
|
is a small YAML in ``<templates>/extensions/`` and everything below is composed
|
|
from those files -- dropping one in makes the extension appear, with no Python
|
|
change, exactly as dropping an engine YAML does.
|
|
|
|
Three things this module is responsible for:
|
|
|
|
1. **Discovery.** :func:`extension_catalog` — what exists, which engine
|
|
templates can host each one, and which installed instances can.
|
|
2. **Compatibility.** Matching is on the engine template's *own*
|
|
``engine.protocol``, so a new PostgreSQL-speaking engine template becomes a
|
|
host automatically and neither side names the other.
|
|
3. **The image problem, which is the whole difficulty.**
|
|
|
|
``CREATE EXTENSION vector`` is a correct statement that fails on stock
|
|
``postgres:16``::
|
|
|
|
ERROR: could not open extension control file
|
|
"/usr/share/postgresql/16/extension/vector.control"
|
|
|
|
The image simply does not carry the shared library, and no statement can
|
|
conjure it. ServerKit never recreates a container behind the operator's back,
|
|
so instead:
|
|
|
|
* the extension declares ``images:`` — the repositories that ship it — and a
|
|
listing marks an instance ``image_unsupported`` **before** anything is
|
|
clicked, naming the image that would work;
|
|
* an install *always* asks the engine first (``available_query`` against
|
|
``pg_available_extensions``) and refuses with that same remedy if the
|
|
answer is empty. The probe is authoritative: an operator who built their
|
|
own capable image is allowed even though its name is unknown to us, and an
|
|
image whose name matches but whose library is missing is still refused.
|
|
|
|
The remedy points at real, installable engine templates, found by matching
|
|
the extension's ``images`` against each engine template's own compose image
|
|
— so shipping a capable engine is, again, adding a YAML file.
|
|
"""
|
|
import logging
|
|
import os
|
|
import re
|
|
from typing import Dict, List, Optional
|
|
|
|
import yaml
|
|
|
|
from app.services import database_engine_service as engines
|
|
from app.services.database_service import DatabaseService
|
|
from app.services.template_service import TemplateService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Per-instance compatibility verdicts. The UI renders these; it does not
|
|
# recompute them.
|
|
STATUS_INSTALLED = 'installed' # already present in the database
|
|
STATUS_READY = 'ready' # can be installed right now
|
|
STATUS_IMAGE_UNSUPPORTED = 'image_unsupported' # engine image does not ship it
|
|
STATUS_INCOMPATIBLE = 'incompatible' # wrong engine entirely
|
|
STATUS_UNKNOWN = 'unknown' # not probed / engine unreachable
|
|
|
|
# Which client can execute a statement against which engine protocol. This is a
|
|
# property of ServerKit's clients, not of any extension: adding an EXTENSION
|
|
# needs no entry here, only adding a whole new engine PROTOCOL would -- and the
|
|
# protocol list is already a closed enum in TemplateService.
|
|
_DATABASE_NAME_RE = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_\-]{0,62}$')
|
|
|
|
|
|
def _postgres_exec(context: Dict, sql: str, database: Optional[str]) -> Dict:
|
|
return DatabaseService.docker_pg_execute(
|
|
context['container'], sql,
|
|
database=database or context.get('default_database') or 'postgres',
|
|
user=context.get('admin_user') or 'postgres',
|
|
password=context.get('password'))
|
|
|
|
|
|
def _mysql_exec(context: Dict, sql: str, database: Optional[str]) -> Dict:
|
|
return DatabaseService.docker_mysql_execute(
|
|
context['container'], sql,
|
|
database=database or context.get('default_database'),
|
|
user=context.get('admin_user') or 'root',
|
|
password=context.get('password'))
|
|
|
|
|
|
EXECUTORS = {
|
|
'postgresql': _postgres_exec,
|
|
'mysql': _mysql_exec,
|
|
}
|
|
|
|
|
|
def _err(message: str, status_code: int = 400, **extra) -> Dict:
|
|
payload = {'error': message, 'status_code': status_code}
|
|
payload.update(extra)
|
|
return payload
|
|
|
|
|
|
# ── image identity ───────────────────────────────────────────────────────────
|
|
def _repository(image: str) -> str:
|
|
"""``pgvector/pgvector:pg16`` -> ``pgvector/pgvector``.
|
|
|
|
Strips a digest and a tag (a colon in the last path segment), then a
|
|
registry host, then Docker Hub's implicit ``library/`` namespace, so the
|
|
same image written five ways compares equal.
|
|
"""
|
|
ref = str(image or '').strip().split('@')[0]
|
|
head, sep, tail = ref.rpartition(':')
|
|
if sep and '/' not in tail:
|
|
ref = head
|
|
parts = ref.split('/')
|
|
if len(parts) > 1 and ('.' in parts[0] or ':' in parts[0] or parts[0] == 'localhost'):
|
|
parts = parts[1:]
|
|
if len(parts) == 2 and parts[0] == 'library':
|
|
parts = parts[1:]
|
|
return '/'.join(parts)
|
|
|
|
|
|
def _image_provides(image: Optional[str], declared: List[str]) -> Optional[bool]:
|
|
"""Does ``image`` look like one of the repositories that ship the extension?
|
|
|
|
``None`` when there is nothing to decide from (no image resolved), so a
|
|
caller can tell "no" apart from "don't know".
|
|
"""
|
|
if not declared:
|
|
return True # the extension makes no image claim
|
|
if not image:
|
|
return None
|
|
repository = _repository(image)
|
|
return any(repository == _repository(want) for want in declared)
|
|
|
|
|
|
def _compose_images(template: Dict) -> List[str]:
|
|
"""Image references a template's compose declares."""
|
|
services = ((template or {}).get('compose') or {}).get('services') or {}
|
|
return [str(svc['image']) for svc in services.values()
|
|
if isinstance(svc, dict) and svc.get('image')]
|
|
|
|
|
|
def instance_image(app) -> Optional[str]:
|
|
"""The image an installed engine actually runs.
|
|
|
|
Read from the rendered compose in the app directory first -- it is on disk,
|
|
it is what was installed, and it needs no Docker. Falls back to inspecting
|
|
the live container.
|
|
"""
|
|
root = getattr(app, 'root_path', None)
|
|
if root:
|
|
for filename in ('docker-compose.yml', 'docker-compose.yaml'):
|
|
path = os.path.join(root, filename)
|
|
if not os.path.exists(path):
|
|
continue
|
|
try:
|
|
with open(path, 'r', encoding='utf-8') as fh:
|
|
compose = yaml.safe_load(fh) or {}
|
|
except Exception:
|
|
continue
|
|
services = compose.get('services') or {}
|
|
service = services.get('app') or next(iter(services.values()), None)
|
|
if isinstance(service, dict) and service.get('image'):
|
|
return str(service['image'])
|
|
try:
|
|
from app.services.docker_service import DockerService
|
|
info = DockerService.get_container(getattr(app, 'container_id', None) or app.name)
|
|
except Exception:
|
|
return None
|
|
return ((info or {}).get('Config') or {}).get('Image')
|
|
|
|
|
|
# ── catalog ──────────────────────────────────────────────────────────────────
|
|
def get_extension(extension_id: str) -> Optional[Dict]:
|
|
"""One extension by id, straight off the YAML."""
|
|
return TemplateService.get_extension_template(extension_id)
|
|
|
|
|
|
def _hosts_for(extension: Dict, engine_templates: List[Dict]) -> List[Dict]:
|
|
"""Engine templates that can host ``extension``, and whether each ships it.
|
|
|
|
``provides`` is what makes the remedy actionable: an operator told their
|
|
PostgreSQL cannot host pgvector is also told which engine template can.
|
|
"""
|
|
hosts = []
|
|
for entry in engine_templates:
|
|
engine = entry.get('engine') or {}
|
|
# Both halves matter. Speaking the protocol is not enough -- CockroachDB
|
|
# answers on the PostgreSQL wire and cannot load a single extension --
|
|
# so the engine has to have declared `engine.extensions: true`.
|
|
if not engine.get('supports_extensions'):
|
|
continue
|
|
if engine.get('protocol') != extension['protocol']:
|
|
continue
|
|
if extension['templates'] and entry['id'] not in extension['templates']:
|
|
continue
|
|
fetched = TemplateService.get_template(entry['id'])
|
|
images = _compose_images(fetched['template']) if fetched.get('success') else []
|
|
provides = any(_image_provides(image, extension['images']) for image in images)
|
|
hosts.append({
|
|
'template_id': entry['id'],
|
|
'name': entry.get('name'),
|
|
# The repository, not the compose string: a template's image still
|
|
# carries an unresolved ${IMAGE_TAG} at this point.
|
|
'image': _repository(images[0]) if images else None,
|
|
'provides': bool(provides),
|
|
})
|
|
return hosts
|
|
|
|
|
|
def extension_catalog() -> List[Dict]:
|
|
"""Every declared extension, with who can host it and where it is installed.
|
|
|
|
One round trip is enough for the drawer: which engine templates host it,
|
|
which of those actually ship it, and the id of every installed instance
|
|
that is compatible today.
|
|
"""
|
|
engine_templates = TemplateService.list_engine_templates()
|
|
instances = engine_instances()
|
|
|
|
catalog = []
|
|
for extension in TemplateService.list_extension_templates():
|
|
hosts = _hosts_for(extension, engine_templates)
|
|
host_ids = {h['template_id'] for h in hosts}
|
|
|
|
# "Compatible" means *not known to be incompatible*: an instance whose
|
|
# image could not be read stays offered, because the install itself
|
|
# still asks the engine before it writes anything.
|
|
compatible, needs_image = [], []
|
|
for instance in instances:
|
|
if instance['template_id'] not in host_ids:
|
|
continue
|
|
(needs_image
|
|
if _instance_verdict(instance, extension)[0] == STATUS_IMAGE_UNSUPPORTED
|
|
else compatible).append(instance['app_id'])
|
|
|
|
item = dict(extension)
|
|
item['hosts'] = hosts
|
|
item['provided_by'] = [h['template_id'] for h in hosts if h['provides']]
|
|
item['compatible_instances'] = compatible
|
|
item['incompatible_instances'] = needs_image
|
|
catalog.append(item)
|
|
return catalog
|
|
|
|
|
|
# ── per-instance compatibility ───────────────────────────────────────────────
|
|
def _instance_verdict(instance: Dict, extension: Dict) -> tuple:
|
|
"""Static verdict for one instance/extension pair: ``(status, reason)``.
|
|
|
|
Static because it costs nothing -- no Docker, no exec. It is enough to grey
|
|
a card out in the catalog; :func:`install_extension` still asks the engine
|
|
itself before it writes anything.
|
|
"""
|
|
engine = instance.get('engine') or {}
|
|
protocol = engine.get('protocol')
|
|
if protocol != extension['protocol']:
|
|
return STATUS_INCOMPATIBLE, (
|
|
f"{instance['name']} speaks {protocol or 'no known protocol'}; "
|
|
f"{extension['name']} needs {extension['protocol']}")
|
|
if not engine.get('supports_extensions'):
|
|
return STATUS_INCOMPATIBLE, (
|
|
f"{instance['template_name'] or instance['name']} does not support "
|
|
'database extensions, even though it speaks the same protocol')
|
|
if extension['templates'] and instance['template_id'] not in extension['templates']:
|
|
return STATUS_INCOMPATIBLE, (
|
|
f"{extension['name']} is only offered for "
|
|
f"{', '.join(extension['templates'])}")
|
|
provides = _image_provides(instance.get('image'), extension['images'])
|
|
if provides is False:
|
|
return STATUS_IMAGE_UNSUPPORTED, (
|
|
f"{instance['name']} runs {instance.get('image')}, which does not ship "
|
|
f"{extension['name']}. Enabling it there would fail with "
|
|
'"could not open extension control file".')
|
|
if provides is None:
|
|
return STATUS_UNKNOWN, 'Could not determine which image this engine runs'
|
|
return STATUS_READY, None
|
|
|
|
|
|
def _remedy(extension: Dict, instance: Dict, engine_templates=None) -> Dict:
|
|
"""What the operator can actually do about an image that cannot host it."""
|
|
version = instance.get('engine_version')
|
|
hint = extension.get('image_hint')
|
|
suggested = None
|
|
if hint:
|
|
suggested = hint.replace('{version}', str(version)) if version else hint
|
|
elif extension['images']:
|
|
suggested = extension['images'][0]
|
|
hosts = _hosts_for(extension, engine_templates
|
|
if engine_templates is not None
|
|
else TemplateService.list_engine_templates())
|
|
return {
|
|
'kind': 'image',
|
|
'current_image': instance.get('image'),
|
|
'required_images': extension['images'],
|
|
'suggested_image': suggested,
|
|
# Engine templates whose own image ships it -- installable today.
|
|
'install_instead': [h for h in hosts if h['provides']],
|
|
'action': (
|
|
'This engine runs an image without the extension, and no statement can '
|
|
'add it. Install an engine whose image ships it and point the app at '
|
|
f"that instance{', for example ' + suggested if suggested else ''}. "
|
|
'ServerKit will not silently recreate this container: doing so would '
|
|
'restart every connection to it.'),
|
|
}
|
|
|
|
|
|
def instance_extensions(app_id: int, probe: bool = False) -> Dict:
|
|
"""Every extension, judged against ONE installed engine.
|
|
|
|
``probe=True`` replaces the static image verdict with the engine's own
|
|
answer, which is slower (a docker exec per extension) but authoritative.
|
|
"""
|
|
instance = _find_instance(app_id)
|
|
if instance is None:
|
|
return _err(f'No installed database engine with id {app_id}', 404)
|
|
|
|
engine_templates = TemplateService.list_engine_templates()
|
|
results = []
|
|
for extension in TemplateService.list_extension_templates():
|
|
status, reason = _instance_verdict(instance, extension)
|
|
item = dict(extension)
|
|
item['installed_version'] = None
|
|
|
|
if probe and status in (STATUS_READY, STATUS_IMAGE_UNSUPPORTED, STATUS_UNKNOWN):
|
|
status, reason, item['installed_version'] = _probe(instance, extension,
|
|
status, reason)
|
|
|
|
item['status'] = status
|
|
item['reason'] = reason
|
|
item['remedy'] = (_remedy(extension, instance, engine_templates)
|
|
if status == STATUS_IMAGE_UNSUPPORTED else None)
|
|
results.append(item)
|
|
|
|
return {'instance': _public_instance(instance), 'extensions': results,
|
|
'probed': bool(probe)}
|
|
|
|
|
|
def _probe(instance: Dict, extension: Dict, status: str, reason: Optional[str]) -> tuple:
|
|
"""Ask the running engine what it can load, and what it already has."""
|
|
executor = EXECUTORS.get(extension['protocol'])
|
|
if executor is None or not extension.get('available_query'):
|
|
return status, reason, None
|
|
|
|
context = _execution_context(instance)
|
|
result = executor(context, extension['available_query'], None)
|
|
if not result.get('success'):
|
|
# Falling back to the static verdict is deliberate: a stopped container
|
|
# must not turn "your image cannot host this" into "maybe".
|
|
if status == STATUS_IMAGE_UNSUPPORTED:
|
|
return status, reason, None
|
|
return STATUS_UNKNOWN, (result.get('error') or '').strip() or \
|
|
'Could not reach the engine', None
|
|
if not (result.get('output') or '').strip():
|
|
return STATUS_IMAGE_UNSUPPORTED, (
|
|
f"{instance['name']} cannot load {extension['name']}: the running image "
|
|
'does not ship it.'), None
|
|
|
|
version = None
|
|
if extension.get('installed_query'):
|
|
installed = executor(context, extension['installed_query'], None)
|
|
version = (installed.get('output') or '').strip() or None
|
|
return (STATUS_INSTALLED if version else STATUS_READY), None, version
|
|
|
|
|
|
# ── install ──────────────────────────────────────────────────────────────────
|
|
def install_extension(app_id: int, extension_id: str, database: str = None) -> Dict:
|
|
"""Run an extension's statement against one database on one instance.
|
|
|
|
Never executes the statement without first confirming the engine can load
|
|
it, so the failure mode is a 409 naming the image to use, not a raw
|
|
``could not open extension control file`` surfaced as a generic error.
|
|
"""
|
|
extension = get_extension(extension_id)
|
|
if extension is None:
|
|
return _err(f"Unknown extension '{extension_id}'", 404,
|
|
available=[e['id'] for e in TemplateService.list_extension_templates()])
|
|
|
|
instance = _find_instance(app_id)
|
|
if instance is None:
|
|
return _err(f'No installed database engine with id {app_id}', 404)
|
|
|
|
status, reason = _instance_verdict(instance, extension)
|
|
if status == STATUS_INCOMPATIBLE:
|
|
return _err(reason, 400,
|
|
hosts=[h['template_id']
|
|
for h in _hosts_for(extension, TemplateService.list_engine_templates())])
|
|
|
|
executor = EXECUTORS.get(extension['protocol'])
|
|
if executor is None:
|
|
return _err(f"ServerKit has no client for {extension['protocol']}, so it "
|
|
'cannot install extensions into it', 400)
|
|
|
|
database = (database or instance.get('default_database') or '').strip() or None
|
|
if database and not _DATABASE_NAME_RE.match(database):
|
|
return _err(f"'{database}' is not a valid database name", 400)
|
|
|
|
context = _execution_context(instance)
|
|
|
|
# 1. Ask the engine itself. This is the check that matters: it is the only
|
|
# one that knows whether the running image carries the control file.
|
|
if extension.get('available_query'):
|
|
probe = executor(context, extension['available_query'], database)
|
|
if not probe.get('success'):
|
|
if status == STATUS_IMAGE_UNSUPPORTED:
|
|
return _err(reason, 409, status=STATUS_IMAGE_UNSUPPORTED,
|
|
remedy=_remedy(extension, instance))
|
|
return _err(
|
|
f"Could not reach {instance['name']} to check whether it can load "
|
|
f"{extension['name']}: {(probe.get('error') or '').strip() or 'no answer'}",
|
|
502, status=STATUS_UNKNOWN)
|
|
if not (probe.get('output') or '').strip():
|
|
return _err(
|
|
f"{instance['name']} cannot load {extension['name']}: its image "
|
|
f"({instance.get('image') or 'unknown'}) does not ship the extension. "
|
|
'Installing it would fail with "could not open extension control file".',
|
|
409, status=STATUS_IMAGE_UNSUPPORTED,
|
|
remedy=_remedy(extension, instance))
|
|
elif status == STATUS_IMAGE_UNSUPPORTED:
|
|
# No probe to fall back on -- the declared image requirement is all we
|
|
# have, and it says no.
|
|
return _err(reason, 409, status=STATUS_IMAGE_UNSUPPORTED,
|
|
remedy=_remedy(extension, instance))
|
|
|
|
# 2. The statement comes from the extension's YAML, never from the request.
|
|
result = executor(context, extension['statement'], database)
|
|
if not result.get('success'):
|
|
return _err(
|
|
f"{extension['name']} could not be installed into "
|
|
f"{database or instance['name']}: "
|
|
f"{(result.get('error') or '').strip() or 'the engine rejected the statement'}",
|
|
400, status=STATUS_UNKNOWN)
|
|
|
|
version = None
|
|
if extension.get('installed_query'):
|
|
installed = executor(context, extension['installed_query'], database)
|
|
version = (installed.get('output') or '').strip() or None
|
|
|
|
return {
|
|
'success': True,
|
|
'status': STATUS_INSTALLED,
|
|
'extension': {'id': extension['id'], 'name': extension['name'],
|
|
'version': version or extension['version']},
|
|
'instance': _public_instance(instance),
|
|
'database': database,
|
|
'statement': extension['statement'],
|
|
}
|
|
|
|
|
|
# ── instance plumbing ────────────────────────────────────────────────────────
|
|
def engine_instances() -> List[Dict]:
|
|
"""Every installed engine, described for extension purposes."""
|
|
from app.models import Application
|
|
|
|
described = []
|
|
for app in Application.query_active().order_by(Application.created_at.asc()).all():
|
|
record = _describe(app)
|
|
if record:
|
|
described.append(record)
|
|
return described
|
|
|
|
|
|
def _find_instance(app_id: int) -> Optional[Dict]:
|
|
"""One installed engine by app id, or ``None`` when it is not an engine."""
|
|
from app.models import Application
|
|
|
|
app = Application.query_active().filter_by(id=app_id).first()
|
|
return _describe(app) if app is not None else None
|
|
|
|
|
|
def _describe(app) -> Optional[Dict]:
|
|
"""An installed engine, enriched with the bits extensions need.
|
|
|
|
Engine-ness is decided by ``database_engine_service`` (a template carrying
|
|
an ``engine:`` block), so "what counts as an installed engine" is still
|
|
answered in exactly one place.
|
|
"""
|
|
template_id = engines.app_template_id(app)
|
|
if not template_id:
|
|
return None
|
|
fetched = TemplateService.get_template(template_id)
|
|
if not fetched.get('success'):
|
|
return None
|
|
template = fetched['template']
|
|
# No `engine:` block, no engine -- an ordinary app cannot host an extension.
|
|
engine = TemplateService.engine_metadata(template)
|
|
if not engine:
|
|
return None
|
|
variables = ((engines._install_info(app) or {}).get('variables') or {})
|
|
version_var = (template.get('engine') or {}).get('version_var') or 'IMAGE_TAG'
|
|
|
|
return {
|
|
'app_id': app.id,
|
|
'name': app.name,
|
|
'status': app.status,
|
|
'container': getattr(app, 'container_id', None) or app.name,
|
|
'template_id': template_id,
|
|
'template_name': template.get('name'),
|
|
'engine': engine,
|
|
'engine_version': variables.get(version_var) or template.get('version'),
|
|
'image': instance_image(app),
|
|
'default_database': variables.get(engine.get('database_var') or ''),
|
|
'admin_user': engine.get('admin_user'),
|
|
'password': variables.get(engine.get('admin_password_var') or ''),
|
|
}
|
|
|
|
|
|
def _execution_context(instance: Dict) -> Dict:
|
|
return {
|
|
'container': instance['container'],
|
|
'admin_user': instance.get('admin_user'),
|
|
'password': instance.get('password'),
|
|
'default_database': instance.get('default_database'),
|
|
}
|
|
|
|
|
|
def _public_instance(instance: Dict) -> Dict:
|
|
"""The instance as the API may describe it — never the admin secret."""
|
|
return {
|
|
'app_id': instance['app_id'],
|
|
'name': instance['name'],
|
|
'status': instance['status'],
|
|
'template_id': instance['template_id'],
|
|
'template_name': instance['template_name'],
|
|
'protocol': (instance.get('engine') or {}).get('protocol'),
|
|
'engine_version': instance.get('engine_version'),
|
|
'image': instance.get('image'),
|
|
'default_database': instance.get('default_database'),
|
|
}
|