mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-02 18:17:11 +09:00
Comments, docstrings and a few strings cited plan numbers ("plan 29
#9", "plan 86 §B2") from a git-ignored folder, so they pointed at
nothing; one reached users in a bottleneck hint. Code and frontend
strings are unchanged apart from those citations (checked by AST and
token comparison). A vhost still carrying the old asset comment reads
as undrifted, and a guard test keeps new citations out.
393 lines
15 KiB
Python
393 lines
15 KiB
Python
"""One-shot auto-install of converted builtin extensions (decision D3).
|
|
|
|
When a core page is converted into a builtin extension, an *upgraded* panel must
|
|
not lose the feature. On the first boot after such an upgrade we auto-install the
|
|
extension once, recording a per-slug marker so we never do it again — the user is
|
|
free to uninstall afterwards and it stays uninstalled.
|
|
|
|
Fresh installs are different: they never had the page as a core feature, so they
|
|
should discover the extension in the Marketplace rather than have it pre-installed
|
|
(flagships that ship installed-by-default per D4 are handled separately, not here).
|
|
We distinguish the two by whether any user exists yet — a brand-new panel has none
|
|
until the setup wizard creates the first admin.
|
|
|
|
The whole pass is best-effort: a failure to install (e.g. a Docker panel whose
|
|
frontend plugins dir isn't writable) is logged and retried on the next boot, and
|
|
never blocks startup. The builtin frontends are pre-bundled (D5) regardless, so
|
|
the marker only governs whether the contribution is switched on.
|
|
"""
|
|
import json
|
|
import logging
|
|
import os
|
|
import shutil
|
|
|
|
from app import db
|
|
from app.models.plugin import InstalledPlugin
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Builtin extensions that were previously shipped as core pages. Append a slug
|
|
# here when its page is converted; existing panels then auto-install it once.
|
|
CONVERTED_BUILTIN_SLUGS = [
|
|
'serverkit-ftp',
|
|
'serverkit-cloud-provision',
|
|
'serverkit-remote-access',
|
|
'serverkit-status',
|
|
]
|
|
|
|
# Extensions deliberately deleted from the product (replaced or retired) — the
|
|
# Workflow Builder was superseded by serverkit-tramo. Their leftover
|
|
# copy-installed files must be swept from the live tree: update.sh's plugin
|
|
# carry-forward would otherwise preserve them into every fresh deploy, where
|
|
# their dead imports (workflows → reactflow) sink the bundle build. The updater
|
|
# has its own skip list (RETIRED_PLUGIN_SLUGS in scripts/update.sh); this sweep
|
|
# is the backstop for trees updated by an older/offline updater.
|
|
RETIRED_EXTENSION_SLUGS = [
|
|
'serverkit-workflows',
|
|
]
|
|
|
|
|
|
def remove_retired_extensions():
|
|
"""Sweep files and DB rows of retired extensions. Idempotent, best-effort.
|
|
|
|
Runs at boot BEFORE the plugin loader so a retired backend plugin is never
|
|
loaded and the repair pass never tries to resurrect its row.
|
|
"""
|
|
from app.services.plugin_service import (
|
|
BACKEND_PLUGINS_DIR,
|
|
FRONTEND_PLUGINS_DIR,
|
|
)
|
|
for slug in RETIRED_EXTENSION_SLUGS:
|
|
for base in (BACKEND_PLUGINS_DIR, FRONTEND_PLUGINS_DIR):
|
|
path = os.path.join(base, slug)
|
|
if os.path.isdir(path):
|
|
try:
|
|
shutil.rmtree(path)
|
|
logger.info(f'Removed retired extension files: {path}')
|
|
except OSError as e:
|
|
logger.warning(f'Could not remove retired extension {path}: {e}')
|
|
try:
|
|
row = InstalledPlugin.query.filter_by(slug=slug).first()
|
|
if row:
|
|
db.session.delete(row)
|
|
db.session.commit()
|
|
logger.info(f'Removed retired extension row: {slug}')
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning(f'Could not remove retired extension row {slug}: {e}')
|
|
|
|
|
|
def _gitea_was_used():
|
|
"""True if this panel actually runs the self-hosted Gitea the git
|
|
extension manages — the panel-managed application row exists. Gates
|
|
serverkit-git auto-install after its backend split so
|
|
only Gitea operators get the extension back automatically; deploys from
|
|
external remotes never depended on it."""
|
|
try:
|
|
from app.models import Application
|
|
return (Application.query.filter_by(name='serverkit-gitea').first()
|
|
is not None)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _email_was_configured():
|
|
"""True if this panel actually ran a mail server before the extraction — any
|
|
email domain/account row exists. Used to gate serverkit-email auto-install so
|
|
only mail users get it back automatically (#34); everyone else uses the
|
|
Marketplace."""
|
|
try:
|
|
from app.models.email import EmailDomain, EmailAccount
|
|
return (db.session.query(EmailDomain.id).first() is not None
|
|
or db.session.query(EmailAccount.id).first() is not None)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
# Builtins auto-installed on upgrade ONLY when a usage predicate says the panel
|
|
# actually used the feature (D3/#34). Fresh installs and panels that never used
|
|
# the feature just see it in the Marketplace.
|
|
# Empty since the registry cutover — every gated slug rides the registry
|
|
# now. The dict (and its predicate wiring) stays: the next core->builtin
|
|
# conversion drops straight in.
|
|
GATED_BUILTIN_SLUGS = {}
|
|
|
|
|
|
def _has_command(cmd):
|
|
def probe():
|
|
try:
|
|
from app.utils.system import is_command_available
|
|
return is_command_available(cmd)
|
|
except Exception:
|
|
return False
|
|
return probe
|
|
|
|
|
|
def _scanners_dir_populated():
|
|
"""True if grype/syft were ever installed by the panel's image scanner."""
|
|
try:
|
|
from app import paths
|
|
scanners = os.path.join(paths.SERVERKIT_DATA_DIR, 'scanners')
|
|
return os.path.isdir(scanners) and bool(os.listdir(scanners))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
# Security-suite extractions. These left
|
|
# core for REGISTRY extensions — there is no builtin folder to copy, so
|
|
# upgrade parity means: when the host tool the old core surface managed is
|
|
# actually present, install the extension from the registry once. Fail-soft:
|
|
# an unreachable registry (or an entry not yet published) leaves the slug
|
|
# unmarked so the next boot retries; panels without the host tool just see
|
|
# the extension in the Marketplace.
|
|
REGISTRY_GATED_SLUGS = {
|
|
# serverkit-email / serverkit-git left the tree: their gated
|
|
# auto-installs ride the registry now, same
|
|
# predicates as before.
|
|
'serverkit-email': _email_was_configured,
|
|
'serverkit-git': _gitea_was_used,
|
|
'serverkit-fail2ban': _has_command('fail2ban-client'),
|
|
'serverkit-clamav': _has_command('clamscan'),
|
|
'serverkit-lynis': _has_command('lynis'),
|
|
'serverkit-auto-updates': _has_command('unattended-upgrade'),
|
|
'serverkit-image-scan': _scanners_dir_populated,
|
|
}
|
|
|
|
_MARKER_KEY = 'extensions.auto_installed_slugs'
|
|
# Slugs whose now-extracted backend has been re-acquired on an upgraded panel.
|
|
# Separate from _MARKER_KEY so the one-shot backend pickup
|
|
# runs even for slugs already auto-installed frontend-only in a prior release.
|
|
_BACKEND_ACQUIRED_KEY = 'extensions.backend_acquired_slugs'
|
|
|
|
|
|
def _processed_slugs():
|
|
from app.services.settings_service import SettingsService
|
|
raw = SettingsService.get(_MARKER_KEY, '')
|
|
if not raw:
|
|
return set()
|
|
try:
|
|
return set(json.loads(raw))
|
|
except (ValueError, TypeError):
|
|
# Tolerate a legacy comma-joined value.
|
|
return {s.strip() for s in str(raw).split(',') if s.strip()}
|
|
|
|
|
|
def _save_processed(slugs):
|
|
from app.services.settings_service import SettingsService
|
|
SettingsService.set(_MARKER_KEY, json.dumps(sorted(slugs)))
|
|
|
|
|
|
def _looks_like_existing_install():
|
|
"""True if this panel has prior data (an upgrade), False if brand-new.
|
|
|
|
A fresh install has no users yet — the setup wizard creates the first admin.
|
|
"""
|
|
from app.models.user import User
|
|
try:
|
|
return db.session.query(User.id).first() is not None
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def run_auto_install():
|
|
"""Install converted builtins once on an upgraded panel. Idempotent."""
|
|
processed = _processed_slugs()
|
|
existing = _looks_like_existing_install()
|
|
|
|
try:
|
|
from app.services.plugin_service import (
|
|
install_builtin_extension,
|
|
list_builtin_extensions,
|
|
)
|
|
available = {e['slug'] for e in list_builtin_extensions()}
|
|
except Exception as e:
|
|
logger.warning(f'Extension auto-install skipped (builtins unavailable): {e}')
|
|
return
|
|
|
|
# Ungated converted builtins auto-install on any upgrade; gated ones only when
|
|
# their usage predicate is true.
|
|
candidates = [(s, None) for s in CONVERTED_BUILTIN_SLUGS]
|
|
candidates += [(s, gate) for s, gate in GATED_BUILTIN_SLUGS.items()]
|
|
|
|
changed = False
|
|
for slug, gate in candidates:
|
|
if slug in processed:
|
|
continue
|
|
|
|
# Not yet shipped as a builtin folder — skip quietly, revisit next boot.
|
|
if slug not in available:
|
|
continue
|
|
|
|
if existing:
|
|
# Upgrade path: keep the feature alive unless it's already present or
|
|
# (for gated builtins) the panel never used it.
|
|
wants_it = True
|
|
if gate is not None:
|
|
try:
|
|
wants_it = bool(gate())
|
|
except Exception:
|
|
wants_it = False
|
|
if wants_it and not InstalledPlugin.query.filter_by(slug=slug).first():
|
|
try:
|
|
install_builtin_extension(slug)
|
|
logger.info(f'Auto-installed converted builtin extension: {slug}')
|
|
except Exception as e:
|
|
logger.warning(
|
|
f'Auto-install of {slug} failed (retry next boot): {e}'
|
|
)
|
|
continue # leave unmarked so we retry
|
|
# Fresh install (marketplace-only), gate-false (marketplace-only), OR
|
|
# successfully installed: record it so the one-shot never repeats.
|
|
processed.add(slug)
|
|
changed = True
|
|
|
|
if changed:
|
|
_save_processed(processed)
|
|
|
|
|
|
_REGISTRY_MARKER_KEY = 'extensions.registry_auto_installed_slugs'
|
|
|
|
|
|
def _registry_processed_slugs():
|
|
from app.services.settings_service import SettingsService
|
|
raw = SettingsService.get(_REGISTRY_MARKER_KEY, '')
|
|
if not raw:
|
|
return set()
|
|
try:
|
|
return set(json.loads(raw))
|
|
except (ValueError, TypeError):
|
|
return set()
|
|
|
|
|
|
def run_registry_auto_install():
|
|
"""Upgrade parity for the extracted security suite. Idempotent, fail-soft.
|
|
|
|
Only on an upgraded panel, only for slugs whose host-tool predicate is
|
|
true, and only when the registry actually lists the slug (an unpublished
|
|
or unreachable registry defers to the next boot instead of burning the
|
|
one-shot). Fresh installs go through the wizard/Marketplace instead.
|
|
"""
|
|
processed = _registry_processed_slugs()
|
|
pending = {s: gate for s, gate in REGISTRY_GATED_SLUGS.items()
|
|
if s not in processed}
|
|
if not pending:
|
|
return
|
|
|
|
if not _looks_like_existing_install():
|
|
# Fresh install: never auto-install, mark everything done once.
|
|
_save_registry_processed(processed | set(pending))
|
|
return
|
|
|
|
from app.services import registry_service
|
|
from app.services.plugin_service import install_registry_extension
|
|
|
|
changed = False
|
|
for slug, gate in pending.items():
|
|
try:
|
|
wants_it = bool(gate())
|
|
except Exception:
|
|
wants_it = False
|
|
if not wants_it:
|
|
processed.add(slug)
|
|
changed = True
|
|
continue
|
|
if InstalledPlugin.query.filter_by(slug=slug).first():
|
|
processed.add(slug)
|
|
changed = True
|
|
continue
|
|
try:
|
|
entry = registry_service.get_entry(slug)
|
|
except Exception:
|
|
entry = None
|
|
if not entry:
|
|
# Not published / registry unreachable — retry next boot.
|
|
continue
|
|
try:
|
|
install_registry_extension(slug)
|
|
logger.info(f'Auto-installed security extension from registry: {slug}')
|
|
processed.add(slug)
|
|
changed = True
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning(
|
|
f'Registry auto-install of {slug} failed (retry next boot): {e}')
|
|
|
|
if changed:
|
|
_save_registry_processed(processed)
|
|
|
|
|
|
def _save_registry_processed(slugs):
|
|
from app.services.settings_service import SettingsService
|
|
SettingsService.set(_REGISTRY_MARKER_KEY, json.dumps(sorted(slugs)))
|
|
|
|
|
|
def _backend_acquired_slugs():
|
|
from app.services.settings_service import SettingsService
|
|
raw = SettingsService.get(_BACKEND_ACQUIRED_KEY, '')
|
|
if not raw:
|
|
return set()
|
|
try:
|
|
return set(json.loads(raw))
|
|
except (ValueError, TypeError):
|
|
return {s.strip() for s in str(raw).split(',') if s.strip()}
|
|
|
|
|
|
def run_backend_acquisition():
|
|
"""Re-acquire the backend for converted builtins that gained one.
|
|
|
|
Some builtins (ftp, cloud-provision, remote-access, status) first shipped as
|
|
*frontend-only* extensions whose API was still served by CORE. The lean-core split moves
|
|
that backend into the extension and deregisters it from core. On an upgraded
|
|
panel that already installed such an extension frontend-only, the installed
|
|
row has ``has_backend=False`` and no backend on disk — so without this pass
|
|
the API would simply vanish. For each converted slug that is installed but
|
|
missing the now-available backend, force-reinstall from ``builtin-extensions/``
|
|
once (idempotent via a marker). Best-effort; never blocks startup.
|
|
"""
|
|
import os
|
|
|
|
done = _backend_acquired_slugs()
|
|
try:
|
|
from app.services import plugin_service
|
|
builtins = {e['slug']: e for e in plugin_service.list_builtin_extensions()}
|
|
except Exception as e:
|
|
logger.warning(f'Backend acquisition skipped (builtins unavailable): {e}')
|
|
return
|
|
|
|
changed = False
|
|
# Gated slugs join the sweep: acquisition only ever fires for a slug the
|
|
# panel already INSTALLED whose builtin now ships a backend it lacks on
|
|
# disk (serverkit-git gained one in its split), so the
|
|
# usage gate has already been answered by the install itself.
|
|
for slug in list(CONVERTED_BUILTIN_SLUGS) + list(GATED_BUILTIN_SLUGS):
|
|
if slug in done:
|
|
continue
|
|
entry = builtins.get(slug)
|
|
# Only slugs that now actually ship a backend/ dir are candidates.
|
|
if not entry or not os.path.isdir(os.path.join(entry['path'], 'backend')):
|
|
continue
|
|
row = InstalledPlugin.query.filter_by(slug=slug).first()
|
|
if not row:
|
|
# Not installed — run_auto_install handles fresh (re-)acquisition,
|
|
# and a fresh panel discovers it in the Marketplace.
|
|
continue
|
|
backend_on_disk = os.path.isdir(
|
|
os.path.join(plugin_service.BACKEND_PLUGINS_DIR, slug))
|
|
if row.has_backend and backend_on_disk:
|
|
done.add(slug)
|
|
changed = True
|
|
continue
|
|
try:
|
|
plugin_service.install_from_path(
|
|
entry['path'], user_id=row.installed_by, force=True,
|
|
source_type='builtin')
|
|
logger.info(f'Acquired extracted backend for {slug}')
|
|
done.add(slug)
|
|
changed = True
|
|
except Exception as e:
|
|
logger.warning(
|
|
f'Backend acquisition for {slug} failed (retry next boot): {e}')
|
|
continue # leave unmarked so we retry
|
|
|
|
if changed:
|
|
from app.services.settings_service import SettingsService
|
|
SettingsService.set(_BACKEND_ACQUIRED_KEY, json.dumps(sorted(done)))
|