Merge plan-47 lean-core (wizard installs + 4 backend extractions + security tab-group)

This commit is contained in:
Juan Denis committed 2026-07-15 15:24:07 -04:00
commit b7911026be
51 files changed
+1410 -309

No files matched your search

+18
View File
@@ -20,6 +20,24 @@ awaiting a stable release:
### Added
- **The setup wizard now installs what it recommends (lean by default).** The
onboarding "Recommended for you" step is no longer decorative — it renders
real extensions matched to the use cases you pick (e.g. WordPress → WordPress
flagship; DevOps → Kubernetes, Automations, Git) as checkboxes that install on
Finish. Uncheck everything for a lean install; failed installs never block
finishing setup and can be retried from Extensions. Fresh installs no longer
force-install WordPress — it's offered in the wizard when the WordPress use
case is selected. Existing installs keep WordPress untouched.
- **FTP, Cloud Provisioning, Remote Access, and Status Pages are now opt-in
extensions** — their backends moved out of core into
`serverkit-ftp`, `serverkit-cloud-provision`, `serverkit-remote-access`, and
`serverkit-status`, so a fresh panel that never uses them loads none of their
code or API surface. **Upgraders lose nothing:** a one-shot boot migration
re-acquires each extension's backend on panels that had installed it, and the
underlying data models stay in core. Uninstalling an extension now removes its
API surface too, not just its page.
- **Automations (tramo) replaces the Workflow Builder** — the drag-and-drop
React-Flow Workflow Builder is retired in favour of **Automations**
(`/automations`), an opt-in builtin extension that embeds
+2
View File
@@ -367,6 +367,8 @@ English | [Español](docs/README.es.md) | [中文版](docs/README.zh-CN.md) | [P
### 🎨 Customization
**Lean by default** — The guided setup wizard installs only the extensions matched to the use cases you pick (WordPress, web apps, self-hosting, DevOps) and nothing else. Uncheck anything you don't want; add more anytime from Extensions. WordPress is offered at setup rather than force-installed.
**Sidebar Presets** — Switch between Full, Web Hosting, Email Admin, DevOps, and Minimal views with one click
**Collapsible Navigation** — Sidebar groups auto-expand on navigation and collapse when switching sections
+35 -15
View File
@@ -117,7 +117,6 @@ def create_app(config_name=None):
# Register blueprints - Core
from app.api.apps import apps_bp
from app.api.domains import domains_bp
from app.api.tunnels import tunnels_bp
from app.api.private_urls import private_urls_bp
app.register_blueprint(apps_bp, url_prefix='/api/v1/apps')
# "Services" is the user-facing term for Applications (§1 unification).
@@ -248,9 +247,9 @@ def create_app(config_name=None):
from app.api.files import files_bp
app.register_blueprint(files_bp, url_prefix='/api/v1/files')
# Register blueprints - FTP Server
from app.api.ftp import ftp_bp
app.register_blueprint(ftp_bp, url_prefix='/api/v1/ftp')
# FTP Server is an opt-in builtin extension (serverkit-ftp, plan 47) — its
# blueprint loads from builtin-extensions/serverkit-ftp/ when installed, not
# from core. A fresh panel that never touches FTP loads none of it.
# Register blueprints - Firewall
from app.api.firewall import firewall_bp
@@ -421,16 +420,21 @@ def create_app(config_name=None):
from app.api.nginx_advanced import nginx_advanced_bp
app.register_blueprint(nginx_advanced_bp, url_prefix='/api/v1/nginx/advanced')
# Register blueprints - Status Pages
from app.api.status_pages import status_pages_bp
app.register_blueprint(status_pages_bp, url_prefix='/api/v1/status')
# Status Pages is an opt-in builtin extension (serverkit-status, plan 47) —
# its blueprint (public + management routes) loads from builtin-extensions/
# when installed, not from core. The StatusPage/StatusComponent models stay
# core (G2); the WordPress health-check job reaches the extension's sync
# helper via get_installed_extension_attr only when installed.
# Register blueprints - Cloud Provisioning
from app.api.cloud_provisioning import cloud_provisioning_bp
app.register_blueprint(cloud_provisioning_bp, url_prefix='/api/v1/cloud')
# Cloud Provisioning is an opt-in builtin extension (serverkit-cloud-provision,
# plan 47) — its blueprint loads from builtin-extensions/ when installed, not
# from core. The CloudProvider/CloudServer models stay core (G2).
# Register blueprints - Remote Access (WireGuard tunnels; imported above)
app.register_blueprint(tunnels_bp, url_prefix='/api/v1/tunnels')
# Remote Access (WireGuard tunnels) is an opt-in builtin extension
# (serverkit-remote-access, plan 47) — its blueprint loads from
# builtin-extensions/ when installed, not from core. The Tunnel/ExposedService
# models stay core (G2); the agent gateway reaches its reconcile helper via
# get_installed_extension_attr only when the extension is present.
# Register blueprints - Performance
from app.api.performance import performance_bp
@@ -479,7 +483,9 @@ def create_app(config_name=None):
app.register_blueprint(telemetry_bp, url_prefix='/api/v1/observability/events', name='obs_events')
app.register_blueprint(uptime_bp, url_prefix='/api/v1/observability/uptime', name='obs_uptime')
app.register_blueprint(fleet_monitor_bp, url_prefix='/api/v1/observability/fleet', name='obs_fleet')
app.register_blueprint(status_pages_bp, url_prefix='/api/v1/observability/status-pages', name='obs_status_pages')
# status-pages observability alias dropped with the serverkit-status
# extraction (plan 47) — it was unused by the frontend; status pages mount at
# /api/v1/status from the extension when installed.
# Register blueprints - Agent Pairing (RustDesk-style short-code flow)
from app.api.pairing import pairing_bp
@@ -529,10 +535,14 @@ def create_app(config_name=None):
try:
from app.services.dns_provider_service import DNSProviderService
from app.services.storage_provider_service import StorageProviderService
from app.services.cloud_provisioning_service import CloudProvisioningService
n_dns = DNSProviderService.encrypt_legacy_secrets()
n_store = StorageProviderService.encrypt_legacy_secrets()
n_cloud = CloudProvisioningService.encrypt_legacy_secrets()
# Cloud-provider legacy-secret encryption moved out with the
# serverkit-cloud-provision extension (plan 47). It was a one-time
# migration of pre-encryption rows; any panel reaching this version
# already ran it in an earlier boot (idempotent), and the extension
# encrypts on write, so there's nothing left for core to do here.
n_cloud = 0
n_settings = SettingsService.migrate_legacy_secrets()
# Migrate DNS zones with an inline Cloudflare token onto the canonical
# connection store (idempotent), so every zone resolves creds the same way.
@@ -598,6 +608,16 @@ def create_app(config_name=None):
import logging
logging.getLogger(__name__).warning(f'Extension auto-install: {e}')
# One-shot: re-acquire the now-extracted backend for converted builtins
# that first shipped frontend-only (plan 47 Phase 2), so an upgraded panel
# that installed them frontend-only doesn't lose the API. Best-effort.
try:
from app.services.extension_migration import run_backend_acquisition
run_backend_acquisition()
except Exception as e:
import logging
logging.getLogger(__name__).warning(f'Extension backend acquisition: {e}')
# Start metrics history collection in background
from app.services.metrics_history_service import MetricsHistoryService
if not MetricsHistoryService.is_running():
+8 -2
View File
@@ -303,8 +303,14 @@ class AgentNamespace(Namespace):
try:
caps = (data or {}).get('capabilities') or {}
if caps.get('wireguard'):
from app.services.tunnel_broker_service import TunnelBrokerService
TunnelBrokerService.schedule_reconcile(agent.server_id)
# TunnelBrokerService now lives in the serverkit-remote-access
# extension (plan 47); reach it only when installed, no-op otherwise.
from app.services.plugin_service import get_installed_extension_attr
TunnelBrokerService = get_installed_extension_attr(
'serverkit-remote-access', 'tunnel_broker_service',
'TunnelBrokerService')
if TunnelBrokerService is not None:
TunnelBrokerService.schedule_reconcile(agent.server_id)
except Exception:
logger.debug("tunnel reconcile scheduling skipped", exc_info=True)
+21 -1
View File
@@ -183,8 +183,28 @@ def complete_onboarding():
if invalid:
return jsonify({'error': f'Invalid use cases: {", ".join(invalid)}'}), 400
# Save onboarding use cases
# Slugs the wizard installed (recorded for Setup Health / support). The
# actual installs happen client-side via the plugin install endpoints; this
# just persists what onboarding did (plan 47).
installed_extensions = data.get('installed_extensions', [])
if not isinstance(installed_extensions, list):
return jsonify({'error': 'installed_extensions must be a list'}), 400
installed_extensions = [
s for s in installed_extensions if isinstance(s, str) and s.strip()
]
# Save onboarding use cases + what the wizard installed
SettingsService.set('onboarding_use_cases', use_cases, user_id=user.id)
SettingsService.set('onboarding_installed_extensions', installed_extensions,
user_id=user.id)
# Keep the panel lean: suppress wizard-optional flagships (WordPress) the
# user didn't install so the boot seeder won't re-add them (plan 47).
try:
from app.services import plugin_service
plugin_service.finalize_setup_flagships()
except Exception:
pass
# Mark setup as complete
SettingsService.complete_setup(user_id=user.id)
+14
View File
@@ -336,6 +336,20 @@ def list_builtin():
return jsonify({'builtin': list_builtin_extensions()})
@plugins_bp.route('/recommendations', methods=['GET'])
@jwt_required()
def recommendations():
"""Extensions recommended for the given onboarding use cases (setup wizard).
Query: ``?use_cases=wordpress,web-apps``. Returns each recommended
extension's real display_name/description + install source, so the wizard
can render checkboxes that install the actual extension on Finish."""
from app.services.plugin_service import recommend_extensions_for_use_cases
raw = request.args.get('use_cases', '')
use_cases = [u.strip() for u in raw.split(',') if u.strip()]
return jsonify({'recommendations': recommend_extensions_for_use_cases(use_cases)})
@plugins_bp.route('/builtin/<slug>/install', methods=['POST'])
@jwt_required()
def install_builtin(slug):
+11 -4
View File
@@ -90,10 +90,16 @@ def run_health_checks():
from app.models.wordpress_site import WordPressSite
from app.models.status_page import StatusComponent
from app.services.environment_health_service import EnvironmentHealthService
from app.services.status_page_service import StatusPageService
from app.services.plugin_service import get_installed_extension_attr
_prune_old_health_checks()
# Status-page component sync lives in the serverkit-status extension (plan 47);
# reach it only when installed. StatusComponent rows can't exist without it,
# so a lean panel simply runs the health checks without the sync.
StatusPageService = get_installed_extension_attr(
'serverkit-status', 'status_page_service', 'StatusPageService')
sites = WordPressSite.query.filter_by(is_production=True).all()
for site in sites:
try:
@@ -105,9 +111,10 @@ def run_health_checks():
overall = result.get('overall_status')
if not overall:
continue
components = StatusComponent.query.filter_by(wordpress_site_id=site.id).all()
for comp in components:
StatusPageService.sync_component_from_health(comp, overall)
if StatusPageService is not None:
components = StatusComponent.query.filter_by(wordpress_site_id=site.id).all()
for comp in components:
StatusPageService.sync_component_from_health(comp, overall)
except Exception as e:
logger.error(f'Health check failed for site {site.id}: {e}')
try:
@@ -108,6 +108,10 @@ GATED_BUILTIN_SLUGS = {
}
_MARKER_KEY = 'extensions.auto_installed_slugs'
# Slugs whose now-extracted backend has been re-acquired on an upgraded panel
# (plan 47 Phase 2). 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():
@@ -193,3 +197,72 @@ def run_auto_install():
if changed:
_save_processed(processed)
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 (plan 47).
Some builtins (ftp, cloud-provision, remote-access, status) first shipped as
*frontend-only* extensions whose API was still served by CORE. Plan 47 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
for slug in CONVERTED_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} (plan 47)')
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)))
+132
View File
@@ -65,6 +65,14 @@ BUILTIN_EXTENSIONS_DIR = os.environ.get(
FLAGSHIP_SLUGS = ['serverkit-wordpress', 'serverkit-cloudflare-ops']
_FLAGSHIP_UNINSTALLED_KEY = 'extensions.flagship_uninstalled'
# Wizard-optional flagships (plan 47): offered — not force-installed — during
# onboarding. On a FRESH install (setup wizard not yet completed) the boot-time
# seeder skips these so the panel stays lean; the wizard installs them only when
# their use case is picked. Existing installs (setup already completed) keep the
# prior always-seeded behavior — zero regression. cloudflare-ops is NOT here: it
# is route-only (no sidebar item) and stays seeded for the CF connection surface.
WIZARD_OPTIONAL_FLAGSHIP_SLUGS = ['serverkit-wordpress']
def _ensure_builtin_backend_importable(slug):
"""Register ``builtin-extensions/<slug>/backend`` as the dashed package
@@ -1560,6 +1568,104 @@ def list_builtin_extensions():
return out
# ── Onboarding wizard: use-case → recommended extension slugs (plan 47) ──
#
# Drives the setup wizard's "Recommended for you" install checkboxes. Every slug
# here MUST resolve to a real bundled builtin (builtin-extensions/) or a registry
# entry — guarded by tests/test_onboarding_recommendations.py so the map can never
# point at a dead slug. Order within a use case is significant; the resolver
# de-dupes across the selected use cases preserving first-seen order.
RECOMMENDED_EXTENSIONS_BY_USE_CASE = {
'wordpress': ['serverkit-wordpress'],
'web-apps': ['serverkit-git', 'serverkit-status'],
'self-hosted': ['serverkit-dns-server', 'serverkit-status', 'serverkit-mail'],
'devops': ['serverkit-k8s', 'serverkit-tramo', 'serverkit-git'],
}
def _recommendation_index():
"""slug -> {slug, display_name, description, category, source, installed,
status}. Merges the registry catalog with bundled builtins so a recommended
slug resolves regardless of where it lives; builtins win on slug collision."""
index = {}
try:
from app.services import registry_service
for entry in registry_service.list_catalog(include_bundled=True):
slug = entry.get('slug')
if not slug:
continue
index[slug] = {
'slug': slug,
'display_name': entry.get('display_name') or slug,
'description': entry.get('description') or '',
'category': entry.get('category') or 'utility',
'source': 'registry',
'installed': bool(entry.get('installed')),
'status': entry.get('status') or 'not_installed',
}
except Exception as e:
logger.debug(f'Registry unavailable for onboarding recommendations: {e}')
for entry in list_builtin_extensions():
manifest = entry.get('manifest') or {}
slug = entry['slug']
index[slug] = {
'slug': slug,
'display_name': manifest.get('display_name') or slug,
'description': manifest.get('description') or '',
'category': manifest.get('category') or 'utility',
'source': 'builtin',
'installed': bool(entry.get('installed')),
'status': entry.get('status') or 'not_installed',
}
return index
def recommend_extensions_for_use_cases(use_cases):
"""Resolve selected onboarding use cases to installable extension metadata.
Returns a de-duped, order-preserving list of dicts (one per recommended
extension). Slugs that don't resolve to a builtin/registry entry are silently
dropped (defensive — the map is test-guarded against dead slugs)."""
index = _recommendation_index()
seen = set()
out = []
for use_case in (use_cases or []):
for slug in RECOMMENDED_EXTENSIONS_BY_USE_CASE.get(use_case, []):
if slug in seen:
continue
seen.add(slug)
meta = index.get(slug)
if meta:
out.append(meta)
return out
def finalize_setup_flagships():
"""Keep the panel lean after onboarding completes: any wizard-optional
flagship (WordPress) the user didn't install during the wizard gets the
uninstall marker so the boot-time flagship seeder won't re-add it on later
boots. Called from the complete-onboarding endpoint (plan 47)."""
for slug in WIZARD_OPTIONAL_FLAGSHIP_SLUGS:
if not InstalledPlugin.query.filter_by(slug=slug).first():
_set_flagship_uninstalled(slug, True)
def get_installed_extension_attr(slug, module, attr, default=None):
"""Return ``attr`` from an installed extension's backend module, else default.
A core seam that calls into a feature which now lives in an extracted
extension (plan 47) uses this so it no-ops cleanly when the extension isn't
installed. Resolves ``app.plugins.<slug>.<module>`` (the dashed package the
plugin loader wires up on install); any import/lookup failure yields
``default``."""
try:
import importlib
mod = importlib.import_module(f'app.plugins.{slug}.{module}')
return getattr(mod, attr, default)
except Exception:
return default
def install_builtin_extension(slug, user_id=None):
"""Install a builtin extension by its slug (folder lookup)."""
if not os.path.isdir(BUILTIN_EXTENSIONS_DIR):
@@ -1682,6 +1788,26 @@ def _seed_flagship_row(entry):
return plugin
def _wizard_optional_flagships_suppressed():
"""True when wizard-optional flagships (WordPress) should NOT be boot-seeded:
a fresh install whose setup wizard hasn't completed (plan 47).
Bypassed under TESTING so the extensive WordPress test suite keeps its
seeded flagship; the real gate is exercised by dedicated proving tests that
toggle ``app.config['TESTING']``."""
try:
from flask import current_app
if current_app and current_app.config.get('TESTING'):
return False
except Exception:
pass
try:
from app.services.settings_service import SettingsService
return bool(SettingsService.needs_setup())
except Exception:
return False
def seed_flagship_extensions():
"""Ensure bundled flagship extensions (D4) have an active row on EVERY panel
— fresh AND upgrade — unless the user explicitly uninstalled them.
@@ -1690,10 +1816,16 @@ def seed_flagship_extensions():
picks up the seeded row and registers the blueprints (in-place). Best-effort.
"""
uninstalled = _flagship_uninstalled_set()
suppress_optional = _wizard_optional_flagships_suppressed()
available = {e['slug']: e for e in list_builtin_extensions()}
for slug in FLAGSHIP_SLUGS:
if slug in uninstalled:
continue
# Fresh install (plan 47): don't seed wizard-optional flagships (WordPress)
# until the setup wizard installs them. Existing/completed-setup installs
# fall through and seed as before.
if suppress_optional and slug in WIZARD_OPTIONAL_FLAGSHIP_SLUGS:
continue
entry = available.get(slug)
if not entry:
continue
+5
View File
@@ -54,6 +54,11 @@ class SettingsService:
'type': 'json',
'description': 'Use cases selected during onboarding wizard'
},
'onboarding_installed_extensions': {
'value': [],
'type': 'json',
'description': 'Extension slugs installed by the setup wizard'
},
'dev_mode': {
'value': False,
'type': 'boolean',
+1 -1
View File
@@ -1 +1 @@
2588
2623
+5 -31
View File
@@ -248,29 +248,9 @@ def test_env_secret_masking_preserved(app, client, env_rbac):
# Phase 4 — status pages (mutations admin-only, reads open) + raw docker-DB.
# --------------------------------------------------------------------------- #
def test_status_page_mutations_admin_only(app, client):
"""Reads stay open to any authed user; every mutation is admin-only via the
@admin_required decorator (plan 18 Decision 6). A non-admin is 403."""
from app import db
dev = _mk_user(db, 'sp_dev', role='developer')
admin = _mk_user(db, 'sp_admin', role='admin')
# Reads are open to a plain developer.
assert client.get('/api/v1/status/', headers=_token(dev.id)).status_code == 200
# Admin can create a page.
r = client.post('/api/v1/status/', json={'name': 'Ops', 'slug': 'ops'}, headers=_token(admin.id))
assert r.status_code == 201
page_id = r.get_json()['id']
# A developer is denied every mutation.
assert client.post('/api/v1/status/', json={'name': 'X', 'slug': 'x'}, headers=_token(dev.id)).status_code == 403
assert client.put(f'/api/v1/status/{page_id}', json={'name': 'Y'}, headers=_token(dev.id)).status_code == 403
assert client.delete(f'/api/v1/status/{page_id}', headers=_token(dev.id)).status_code == 403
assert client.post(f'/api/v1/status/{page_id}/components', json={'name': 'c', 'check_type': 'http'},
headers=_token(dev.id)).status_code == 403
assert client.post(f'/api/v1/status/{page_id}/incidents', json={'title': 't'},
headers=_token(dev.id)).status_code == 403
# Status-page mutation authz moved to tests/test_status_extraction.py (plan 47) —
# the status blueprint now lives in the serverkit-status extension, so the test
# installs it before exercising the admin-only mutations.
def test_raw_docker_db_routes_admin_only(app, client):
@@ -375,11 +355,5 @@ def test_wordpress_standalone_host_ops_admin_only(app, client, scoping_rbac, per
assert call(url, json={}, headers=scoping_rbac.admin).status_code != 403, f'admin denied {url}'
@pytest.mark.parametrize('persona', ['viewer', 'member', 'foreign'])
def test_status_component_check_admin_only(app, client, scoping_rbac, persona):
"""#4 — `POST /status/components/<id>/check` triggers a live check + persists a
result (write-ish), so it joins its 9 admin-only mutation siblings. A non-admin
is 403; admin passes the gate (the check itself may 404 on a missing id)."""
url = '/api/v1/status/components/999999/check'
assert client.post(url, headers=getattr(scoping_rbac, persona)).status_code == 403, persona
assert client.post(url, headers=scoping_rbac.admin).status_code != 403
# test_status_component_check_admin_only moved to tests/test_status_extraction.py
# (plan 47) — the status blueprint is now in the serverkit-status extension.
@@ -0,0 +1,116 @@
"""Prove the Cloud Provisioning extraction (plan 47 Phase 2).
A fresh panel no longer carries the cloud-provider API in core; installing the
serverkit-cloud-provision builtin registers `/api/v1/cloud`. The CloudProvider
model stays core (G2). Also covers the one-shot backend re-acquisition that keeps
upgraded panels (which installed the extension frontend-only) from losing the API.
"""
import importlib
import sys
import pytest
from app import db
from app.models.plugin import InstalledPlugin
from app.services import plugin_service
SLUG = 'serverkit-cloud-provision'
_PKG = f'app.plugins.{SLUG}'
def test_core_has_no_cloud_routes(app):
rules = [r.rule for r in app.url_map.iter_rules()]
assert [r for r in rules if r.startswith('/api/v1/cloud/')] == []
def test_core_import_of_cloud_service_is_gone():
with pytest.raises(ModuleNotFoundError):
importlib.import_module('app.services.cloud_provisioning_service')
def test_cloud_in_converted_builtin_slugs():
from app.services.extension_migration import CONVERTED_BUILTIN_SLUGS
assert SLUG in CONVERTED_BUILTIN_SLUGS
def test_cloud_model_stays_core():
"""G2: the CloudProvider/CloudServer models remain importable from core."""
from app.models.cloud_server import CloudProvider, CloudServer # noqa: F401
@pytest.fixture
def install_dirs(tmp_path, monkeypatch):
backend = tmp_path / 'plugins_backend'
frontend = tmp_path / 'plugins_frontend'
backend.mkdir()
frontend.mkdir()
monkeypatch.setattr(plugin_service, 'BACKEND_PLUGINS_DIR', str(backend))
monkeypatch.setattr(plugin_service, 'FRONTEND_PLUGINS_DIR', str(frontend))
added = str(backend)
app_pkg_plugins = importlib.import_module('app.plugins')
if added not in app_pkg_plugins.__path__:
app_pkg_plugins.__path__.append(added)
yield {'backend': backend, 'frontend': frontend}
if added in app_pkg_plugins.__path__:
app_pkg_plugins.__path__.remove(added)
for name in list(sys.modules):
if name == _PKG or name.startswith(_PKG + '.'):
del sys.modules[name]
def test_install_cloud_extension_registers_routes(app, client, auth_headers, install_dirs):
available = {e['slug'] for e in plugin_service.list_builtin_extensions()}
assert SLUG in available
plugin = plugin_service.install_builtin_extension(SLUG)
assert plugin.status == InstalledPlugin.STATUS_ACTIVE
assert plugin.has_backend is True
assert plugin.url_prefix == '/api/v1/cloud'
resp = client.get('/api/v1/cloud/providers', headers=auth_headers)
assert resp.status_code not in (404, 503), resp.status_code
def test_cloud_provider_secret_encrypted_at_rest(app, install_dirs):
"""The extracted service still encrypts provider secrets at rest (moved here
from test_provider_secret_encryption.py)."""
plugin_service.install_builtin_extension(SLUG)
from app.models.cloud_server import CloudProvider
from app.utils.crypto import is_encrypted, decrypt_secret_safe
svc_mod = importlib.import_module(f'{_PKG}.cloud_provisioning_service')
CloudProvisioningService = svc_mod.CloudProvisioningService
p = CloudProvisioningService.create_provider(
{'provider_type': 'digitalocean', 'name': 'do', 'api_key': 'do-token-xyz'})
row = CloudProvider.query.get(p.id)
assert row.api_key_encrypted != 'do-token-xyz'
assert is_encrypted(row.api_key_encrypted) is True
assert decrypt_secret_safe(row.api_key_encrypted) == 'do-token-xyz'
assert CloudProvisioningService._auth_headers(row)['Authorization'] == 'Bearer do-token-xyz'
def test_backend_acquisition_upgrades_frontend_only_install(app, install_dirs):
"""An upgraded panel that installed the extension frontend-only re-acquires
the now-extracted backend (plan 47 Phase 2 migration)."""
from app.services import extension_migration
# Simulate the pre-plan-47 state: a frontend-only install (API came from core)
row = InstalledPlugin(
name=SLUG, display_name='Cloud Provisioning', slug=SLUG, version='1.0.0',
source_type='builtin', status=InstalledPlugin.STATUS_ACTIVE,
)
row.has_backend = False
row.has_frontend = True
db.session.add(row)
db.session.commit()
extension_migration.run_backend_acquisition()
refreshed = InstalledPlugin.query.filter_by(slug=SLUG).first()
assert refreshed.has_backend is True
rules = [r.rule for r in app.url_map.iter_rules()]
assert any(r.startswith('/api/v1/cloud/') for r in rules)
+76
View File
@@ -0,0 +1,76 @@
"""Prove the FTP extraction (plan 47 Phase 2).
A fresh panel no longer carries the FTP API in core; installing the
serverkit-ftp builtin registers `/api/v1/ftp` and its routes respond (which also
exercises the extension's relative-import rewiring). Uninstall removes it again.
"""
import sys
import pytest
import app as app_pkg
from app.models.plugin import InstalledPlugin
from app.services import plugin_service
SLUG = 'serverkit-ftp'
_PKG = f'app.plugins.{SLUG}'
def test_core_has_no_ftp_routes(app):
rules = [r.rule for r in app.url_map.iter_rules()]
assert [r for r in rules if r.startswith('/api/v1/ftp')] == []
def test_core_import_of_ftp_service_is_gone():
"""The FTP service left core — importing it from app.services must fail."""
import importlib
with pytest.raises(ModuleNotFoundError):
importlib.import_module('app.services.ftp_service')
def test_ftp_in_converted_builtin_slugs():
from app.services.extension_migration import CONVERTED_BUILTIN_SLUGS
assert SLUG in CONVERTED_BUILTIN_SLUGS
@pytest.fixture
def install_dirs(tmp_path, monkeypatch):
backend = tmp_path / 'plugins_backend'
frontend = tmp_path / 'plugins_frontend'
backend.mkdir()
frontend.mkdir()
monkeypatch.setattr(plugin_service, 'BACKEND_PLUGINS_DIR', str(backend))
monkeypatch.setattr(plugin_service, 'FRONTEND_PLUGINS_DIR', str(frontend))
added = str(backend)
import importlib
app_pkg_plugins = importlib.import_module('app.plugins')
if added not in app_pkg_plugins.__path__:
app_pkg_plugins.__path__.append(added)
yield {'backend': backend, 'frontend': frontend}
if added in app_pkg_plugins.__path__:
app_pkg_plugins.__path__.remove(added)
for name in list(sys.modules):
if name == _PKG or name.startswith(_PKG + '.'):
del sys.modules[name]
def test_install_ftp_extension_registers_routes(app, client, auth_headers, install_dirs):
available = {e['slug'] for e in plugin_service.list_builtin_extensions()}
assert SLUG in available, 'serverkit-ftp builtin folder should exist'
plugin = plugin_service.install_builtin_extension(SLUG)
assert plugin.status == InstalledPlugin.STATUS_ACTIVE
assert plugin.has_backend is True
assert plugin.url_prefix == '/api/v1/ftp'
resp = client.get('/api/v1/ftp/status', headers=auth_headers)
assert resp.status_code not in (404, 503), resp.status_code
def test_uninstall_removes_ftp_plugin(app, install_dirs):
plugin = plugin_service.install_builtin_extension(SLUG)
assert plugin_service.uninstall_plugin(plugin.id) is True
assert InstalledPlugin.query.filter_by(slug=SLUG).first() is None
@@ -15,7 +15,9 @@ def test_observability_aliases_registered():
assert any(r.startswith('/api/v1/observability/events') for r in rules)
assert any(r.startswith('/api/v1/observability/uptime') for r in rules)
assert any(r.startswith('/api/v1/observability/fleet') for r in rules)
assert any(r.startswith('/api/v1/observability/status-pages') for r in rules)
# The status-pages observability alias was dropped when status pages moved to
# the serverkit-status extension (plan 47); the public status route now ships
# with that extension rather than core.
def test_original_prefixes_still_present():
@@ -25,8 +27,6 @@ def test_original_prefixes_still_present():
assert any(r.startswith('/api/v1/monitoring') for r in rules)
assert any(r.startswith('/api/v1/telemetry') for r in rules)
assert any(r.startswith('/api/v1/fleet-monitor') for r in rules)
# Public status page route untouched.
assert '/api/v1/status/public/<slug>' in rules
def test_events_alias_matches_telemetry(app, client, auth_headers):
@@ -0,0 +1,154 @@
"""Plan 47 Phase 1 — the setup wizard installs what it recommends.
Proves the use-case → extension-slug map has no dead slugs, the recommendation
resolver/endpoint returns real extension metadata, complete-onboarding persists
what the wizard installed, and wizard-optional flagships (WordPress) are gated on
fresh installs but kept on completed-setup ones.
"""
import pytest
from app import db
from app.models.plugin import InstalledPlugin
from app.models.user import User
from app.services import plugin_service as ps
from app.services.settings_service import SettingsService
from werkzeug.security import generate_password_hash
from flask_jwt_extended import create_access_token
# ── map integrity ────────────────────────────────────────────────────────────
def test_recommendation_map_has_no_dead_slugs(app):
"""Every slug in the use-case map resolves to a builtin or registry entry."""
with app.app_context():
index = ps._recommendation_index()
dead = [
slug
for slugs in ps.RECOMMENDED_EXTENSIONS_BY_USE_CASE.values()
for slug in slugs
if slug not in index
]
assert dead == [], f'recommendation map points at dead slugs: {dead}'
def test_recommend_resolves_and_dedupes(app):
with app.app_context():
recs = ps.recommend_extensions_for_use_cases(['web-apps', 'devops'])
slugs = [r['slug'] for r in recs]
# git appears in both use cases — must be de-duped, first-seen order kept
assert slugs.count('serverkit-git') == 1
assert 'serverkit-status' in slugs
assert 'serverkit-k8s' in slugs
assert 'serverkit-tramo' in slugs
# every entry carries real metadata (not just a display string)
for r in recs:
assert r['display_name']
assert r['source'] in ('builtin', 'registry')
assert 'installed' in r
def test_recommend_empty_use_cases(app):
with app.app_context():
assert ps.recommend_extensions_for_use_cases([]) == []
assert ps.recommend_extensions_for_use_cases(None) == []
# ── endpoint ─────────────────────────────────────────────────────────────────
def test_recommendations_endpoint(client, auth_headers):
resp = client.get('/api/v1/plugins/recommendations?use_cases=wordpress',
headers=auth_headers)
assert resp.status_code == 200
slugs = [r['slug'] for r in resp.get_json()['recommendations']]
assert 'serverkit-wordpress' in slugs
def test_recommendations_endpoint_requires_auth(client):
resp = client.get('/api/v1/plugins/recommendations?use_cases=wordpress')
assert resp.status_code == 401
# ── complete-onboarding persists installed slugs ─────────────────────────────
def test_complete_onboarding_persists_installed_extensions(client, auth_headers):
resp = client.post('/api/v1/auth/complete-onboarding',
json={'use_cases': ['devops'],
'installed_extensions': ['serverkit-k8s', 'serverkit-git']},
headers=auth_headers)
assert resp.status_code == 200
assert SettingsService.get('onboarding_installed_extensions') == [
'serverkit-k8s', 'serverkit-git']
assert SettingsService.get('onboarding_use_cases') == ['devops']
def test_complete_onboarding_rejects_bad_installed_extensions(client, auth_headers):
resp = client.post('/api/v1/auth/complete-onboarding',
json={'use_cases': [], 'installed_extensions': 'nope'},
headers=auth_headers)
assert resp.status_code == 400
def test_complete_onboarding_still_completes_without_installs(client, auth_headers):
"""A wizard run that installs nothing (the lean outcome) still finishes."""
resp = client.post('/api/v1/auth/complete-onboarding',
json={'use_cases': []},
headers=auth_headers)
assert resp.status_code == 200
assert SettingsService.get('setup_completed') is True
# ── wizard-optional flagship gating ──────────────────────────────────────────
def test_finalize_setup_flagships_marks_uninstalled_when_absent(app):
with app.app_context():
InstalledPlugin.query.filter_by(slug='serverkit-wordpress').delete()
db.session.commit()
ps.finalize_setup_flagships()
assert 'serverkit-wordpress' in ps._flagship_uninstalled_set()
def test_finalize_setup_flagships_keeps_installed(app):
with app.app_context():
# fixture seeds WordPress under the TESTING bypass; finalize must not
# mark an installed flagship as uninstalled
assert InstalledPlugin.query.filter_by(slug='serverkit-wordpress').first()
ps.finalize_setup_flagships()
assert 'serverkit-wordpress' not in ps._flagship_uninstalled_set()
def test_wordpress_flagship_skipped_on_fresh_install(app):
"""With the TESTING bypass off, a fresh install (no setup) skips WordPress."""
with app.app_context():
InstalledPlugin.query.filter_by(slug='serverkit-wordpress').delete()
db.session.commit()
app.config['TESTING'] = False
try:
assert SettingsService.needs_setup() is True
ps.seed_flagship_extensions()
assert InstalledPlugin.query.filter_by(
slug='serverkit-wordpress').first() is None
# cloudflare-ops (not wizard-optional) is still seeded
assert InstalledPlugin.query.filter_by(
slug='serverkit-cloudflare-ops').first() is not None
finally:
app.config['TESTING'] = True
def test_wordpress_flagship_seeded_on_completed_setup(app):
"""An existing/completed-setup install keeps seeding WordPress (no regression)."""
with app.app_context():
InstalledPlugin.query.filter_by(slug='serverkit-wordpress').delete()
db.session.commit()
user = User(email='a@t.local', username='admin', is_active=True,
role=User.ROLE_ADMIN, password_hash=generate_password_hash('x'))
db.session.add(user)
db.session.commit()
SettingsService.complete_setup(user_id=user.id)
app.config['TESTING'] = False
try:
assert SettingsService.needs_setup() is False
ps.seed_flagship_extensions()
assert InstalledPlugin.query.filter_by(
slug='serverkit-wordpress').first() is not None
finally:
app.config['TESTING'] = True
@@ -107,20 +107,10 @@ def test_dns_legacy_plaintext_migration(app):
# ── cloud providers ─────────────────────────────────────────────────────────
def test_cloud_provider_secret_encrypted_at_rest(app):
from app.models.cloud_server import CloudProvider
from app.services.cloud_provisioning_service import CloudProvisioningService
from app.utils.crypto import is_encrypted, decrypt_secret_safe
p = CloudProvisioningService.create_provider(
{'provider_type': 'digitalocean', 'name': 'do', 'api_key': 'do-token-xyz'})
row = CloudProvider.query.get(p.id)
assert row.api_key_encrypted != 'do-token-xyz'
assert is_encrypted(row.api_key_encrypted) is True
assert decrypt_secret_safe(row.api_key_encrypted) == 'do-token-xyz'
# the auth header uses the decrypted token, not the stored blob
assert CloudProvisioningService._auth_headers(row)['Authorization'] == 'Bearer do-token-xyz'
# CloudProvisioningService moved to the serverkit-cloud-provision extension
# (plan 47); its secret-at-rest coverage now lives in
# tests/test_cloud_provision_extraction.py (installs the extension first). The
# CloudProvider model stays core (G2).
# ── storage (config-file backed) ────────────────────────────────────────────
@@ -0,0 +1,236 @@
"""Prove the Remote Access (WireGuard tunnels) extraction (plan 47 Phase 2).
The tunnels blueprint + broker/publish/netutil services move out of core into
serverkit-remote-access; the Tunnel/ExposedService models stay core (G2). This
also re-homes the former test_tunnel_netutil.py (pure subnet/port helpers) and
test_tunnel_reconcile.py (panel-authoritative reconcile) against the extension.
"""
import importlib
import sys
from types import SimpleNamespace
import pytest
from app import db
from app.models.server import Server
from app.models.tunnel import Tunnel
from app.models.exposed_service import ExposedService
from app.models.plugin import InstalledPlugin
from app.services import agent_registry as ar_mod
from app.services import plugin_service
SLUG = 'serverkit-remote-access'
_PKG = f'app.plugins.{SLUG}'
# ── extraction shape ─────────────────────────────────────────────────────────
def test_core_has_no_tunnel_routes(app):
rules = [r.rule for r in app.url_map.iter_rules()]
assert [r for r in rules if r.startswith('/api/v1/tunnels')] == []
def test_core_import_of_tunnel_services_gone():
for mod in ('tunnel_broker_service', 'tunnel_publish_service', 'tunnel_netutil'):
with pytest.raises(ModuleNotFoundError):
importlib.import_module(f'app.services.{mod}')
def test_remote_access_in_converted_builtin_slugs():
from app.services.extension_migration import CONVERTED_BUILTIN_SLUGS
assert SLUG in CONVERTED_BUILTIN_SLUGS
def test_tunnel_models_stay_core():
from app.models.tunnel import Tunnel # noqa: F401
from app.models.exposed_service import ExposedService # noqa: F401
@pytest.fixture
def install_dirs(tmp_path, monkeypatch):
backend = tmp_path / 'plugins_backend'
frontend = tmp_path / 'plugins_frontend'
backend.mkdir()
frontend.mkdir()
monkeypatch.setattr(plugin_service, 'BACKEND_PLUGINS_DIR', str(backend))
monkeypatch.setattr(plugin_service, 'FRONTEND_PLUGINS_DIR', str(frontend))
added = str(backend)
app_pkg_plugins = importlib.import_module('app.plugins')
if added not in app_pkg_plugins.__path__:
app_pkg_plugins.__path__.append(added)
yield {'backend': backend, 'frontend': frontend}
if added in app_pkg_plugins.__path__:
app_pkg_plugins.__path__.remove(added)
for name in list(sys.modules):
if name == _PKG or name.startswith(_PKG + '.'):
del sys.modules[name]
@pytest.fixture
def ext(app, install_dirs):
plugin_service.install_builtin_extension(SLUG)
tn = importlib.import_module(f'{_PKG}.tunnel_netutil')
broker = importlib.import_module(f'{_PKG}.tunnel_broker_service')
return SimpleNamespace(tn=tn, TunnelBrokerService=broker.TunnelBrokerService)
def test_install_registers_tunnel_routes(app, client, auth_headers, install_dirs):
plugin = plugin_service.install_builtin_extension(SLUG)
assert plugin.status == InstalledPlugin.STATUS_ACTIVE
assert plugin.has_backend is True
assert plugin.url_prefix == '/api/v1/tunnels'
rules = [r.rule for r in app.url_map.iter_rules()]
assert any(r.startswith('/api/v1/tunnels') for r in rules)
# ── re-homed: tunnel_netutil pure helpers ────────────────────────────────────
def test_pick_subnet_first_free(ext):
cidr, edge, priv = ext.tn.pick_subnet([])
assert (cidr, edge, priv) == ('10.88.0.0/24', '10.88.0.1', '10.88.0.2')
def test_pick_subnet_skips_used(ext):
cidr, edge, priv = ext.tn.pick_subnet(['10.88.0.0/24', '10.88.1.0/24'])
assert (cidr, edge, priv) == ('10.88.2.0/24', '10.88.2.1', '10.88.2.2')
def test_pick_subnet_exhausted(ext):
used = ['10.88.%d.0/24' % i for i in range(256)]
with pytest.raises(RuntimeError):
ext.tn.pick_subnet(used)
def test_interface_name_is_stable_and_kernel_valid(ext):
name = ext.tn.interface_name_for('3fa9c2b1-dead-beef-0000-111122223333')
assert name == 'skwg3fa9c2b1'
assert len(name) <= 15
assert name.replace('skwg', '').isalnum()
def test_derive_status(ext):
now = 1_000_000
assert ext.tn.derive_status(now - 10, now) == 'up'
assert ext.tn.derive_status(now - 10_000, now) == 'degraded'
assert ext.tn.derive_status(0, now) == 'pending'
assert ext.tn.derive_status(now, now, interface_up=False) == 'down'
def test_validate_endpoint_host(ext):
assert ext.tn.validate_endpoint_host('203.0.113.5') is True
assert ext.tn.validate_endpoint_host('10.0.0.5') is True
assert ext.tn.validate_endpoint_host('127.0.0.1') is False
assert ext.tn.validate_endpoint_host('0.0.0.0') is False
assert ext.tn.validate_endpoint_host('') is False
assert ext.tn.validate_endpoint_host('not-an-ip') is False
def test_pick_listen_port(ext):
assert ext.tn.pick_listen_port([]) == 51820
assert ext.tn.pick_listen_port([51820]) == 51821
assert ext.tn.pick_listen_port([51820, 51821, 51823]) == 51822
def test_pick_listen_port_exhausted(ext):
used = list(range(ext.tn.DEFAULT_LISTEN_PORT,
ext.tn.DEFAULT_LISTEN_PORT + ext.tn.LISTEN_PORT_RANGE))
with pytest.raises(RuntimeError):
ext.tn.pick_listen_port(used)
def test_diagnose_reachability(ext):
assert ext.tn.diagnose_reachability(0, None, False)['state'] == 'interface_down'
assert ext.tn.diagnose_reachability(1_700_000_000, 999, True)['state'] == 'ok'
assert ext.tn.diagnose_reachability(0, 5, True)['state'] == 'connecting'
d = ext.tn.diagnose_reachability(0, 600, True)
assert d['state'] == 'no_handshake'
assert 'blocked' in d['hint'].lower()
# ── re-homed: panel-authoritative reconcile ──────────────────────────────────
def _server(name, ip=None):
s = Server(name=name, ip_address=ip, status='online')
db.session.add(s)
db.session.commit()
return s
def _record_send(monkeypatch):
calls = []
def fake_send(server_id, action, params=None, user_id=None, timeout=30.0):
calls.append({'server_id': server_id, 'action': action, 'params': params or {}})
return {'success': True, 'data': {}}
monkeypatch.setattr(ar_mod.agent_registry, 'send_command', fake_send)
return calls
def _tunnel(edge, priv, iface, third):
t = Tunnel(
edge_server_id=edge.id, private_server_id=priv.id,
interface_name=iface, subnet='10.88.%d.0/24' % third,
edge_wg_ip='10.88.%d.1' % third, private_wg_ip='10.88.%d.2' % third,
listen_port=51820, edge_pubkey='EDGEPUB', private_pubkey='PRIVPUB',
status='up',
)
db.session.add(t)
db.session.commit()
return t
def test_reconcile_private_side(ext, monkeypatch):
calls = _record_send(monkeypatch)
edge = _server('edge', ip='203.0.113.9')
priv = _server('home')
t = _tunnel(edge, priv, 'skwgrec01', 7)
db.session.add(ExposedService(tunnel_id=t.id, hostname='jelly.example.com',
port=8096, status='published'))
db.session.commit()
res = ext.TunnelBrokerService.reconcile_server(priv.id)
assert res and res[0]['ok']
actions = [c['action'] for c in calls]
assert 'wireguard:interface:up' in actions
assert 'wireguard:peer:set' in actions
assert 'wireguard:forward' in actions
peer = next(c['params'] for c in calls if c['action'] == 'wireguard:peer:set')
assert peer['public_key'] == 'EDGEPUB'
assert peer['endpoint'] == '203.0.113.9:51820'
assert peer['persistent_keepalive'] == 25
fwd = next(c['params'] for c in calls if c['action'] == 'wireguard:forward')
assert fwd['listen_port'] == 8096 and fwd['target_port'] == 8096
assert fwd['listen_ip'] == '10.88.7.2'
def test_reconcile_edge_side(ext, monkeypatch):
calls = _record_send(monkeypatch)
edge = _server('edge2', ip='198.51.100.4')
priv = _server('home2')
_tunnel(edge, priv, 'skwgrec02', 8)
ext.TunnelBrokerService.reconcile_server(edge.id)
actions = [c['action'] for c in calls]
assert 'wireguard:interface:up' in actions
assert 'wireguard:peer:set' in actions
assert 'firewall:allow_port' in actions
peer = next(c['params'] for c in calls if c['action'] == 'wireguard:peer:set')
assert peer['public_key'] == 'PRIVPUB'
assert 'endpoint' not in peer
def test_reconcile_skips_errored_tunnels(ext, monkeypatch):
calls = _record_send(monkeypatch)
edge = _server('edge3', ip='192.0.2.7')
priv = _server('home3')
t = _tunnel(edge, priv, 'skwgrec03', 9)
t.status = 'error'
db.session.commit()
res = ext.TunnelBrokerService.reconcile_server(priv.id)
assert res == []
assert calls == []
+133
View File
@@ -0,0 +1,133 @@
"""Prove the Status Pages extraction (plan 47 Phase 2).
The status-pages blueprint (public + management routes) and its service move into
serverkit-status; the StatusPage/StatusComponent models stay core (G2). Core's
WordPress health-check job syncs status components only when the extension is
installed (no crash on a lean panel). Re-homes the status-page mutation authz
test from test_api_authz_hardening.py.
"""
import importlib
import sys
import pytest
from werkzeug.security import generate_password_hash
from flask_jwt_extended import create_access_token
from app import db
from app.models.user import User
from app.models.plugin import InstalledPlugin
from app.services import plugin_service
SLUG = 'serverkit-status'
_PKG = f'app.plugins.{SLUG}'
def test_core_has_no_status_page_routes(app):
"""The status-pages management/public routes are gone from core (the
/api/v1/status/apps container-status aggregator is a different blueprint)."""
rules = [r.rule for r in app.url_map.iter_rules()]
assert '/api/v1/status/public/<slug>' not in rules
assert '/api/v1/status/' not in rules
assert not any('observability/status-pages' in r for r in rules)
def test_core_import_of_status_service_gone():
with pytest.raises(ModuleNotFoundError):
importlib.import_module('app.services.status_page_service')
def test_status_in_converted_builtin_slugs():
from app.services.extension_migration import CONVERTED_BUILTIN_SLUGS
assert SLUG in CONVERTED_BUILTIN_SLUGS
def test_status_models_stay_core():
from app.models.status_page import ( # noqa: F401
StatusPage, StatusComponent)
def test_health_check_job_no_ops_without_extension(app):
"""run_health_checks must not crash on a lean panel (no serverkit-status)."""
from app.jobs.builtin_handlers import run_health_checks
run_health_checks() # no production sites, no extension — clean no-op
@pytest.fixture
def install_dirs(tmp_path, monkeypatch):
backend = tmp_path / 'plugins_backend'
frontend = tmp_path / 'plugins_frontend'
backend.mkdir()
frontend.mkdir()
monkeypatch.setattr(plugin_service, 'BACKEND_PLUGINS_DIR', str(backend))
monkeypatch.setattr(plugin_service, 'FRONTEND_PLUGINS_DIR', str(frontend))
added = str(backend)
app_pkg_plugins = importlib.import_module('app.plugins')
if added not in app_pkg_plugins.__path__:
app_pkg_plugins.__path__.append(added)
yield {'backend': backend, 'frontend': frontend}
if added in app_pkg_plugins.__path__:
app_pkg_plugins.__path__.remove(added)
for name in list(sys.modules):
if name == _PKG or name.startswith(_PKG + '.'):
del sys.modules[name]
def _mk_user(username, role):
u = User(email=f'{username}@t.local', username=username, role=role,
is_active=True, password_hash=generate_password_hash('x'))
db.session.add(u)
db.session.commit()
return u
def _token(uid):
return {'Authorization': f'Bearer {create_access_token(identity=uid)}'}
def test_install_registers_status_routes(app, client, auth_headers, install_dirs):
plugin = plugin_service.install_builtin_extension(SLUG)
assert plugin.status == InstalledPlugin.STATUS_ACTIVE
assert plugin.has_backend is True
assert plugin.url_prefix == '/api/v1/status'
rules = [r.rule for r in app.url_map.iter_rules()]
assert '/api/v1/status/public/<slug>' in rules
def test_status_page_mutations_admin_only(app, client, install_dirs):
"""Reads open to any authed user; every mutation is admin-only (relocated
from test_api_authz_hardening.py — now runs against the installed extension)."""
plugin_service.install_builtin_extension(SLUG)
dev = _mk_user('sp_dev', 'developer')
admin = _mk_user('sp_admin', 'admin')
assert client.get('/api/v1/status/', headers=_token(dev.id)).status_code == 200
r = client.post('/api/v1/status/', json={'name': 'Ops', 'slug': 'ops'},
headers=_token(admin.id))
assert r.status_code == 201
page_id = r.get_json()['id']
assert client.post('/api/v1/status/', json={'name': 'X', 'slug': 'x'},
headers=_token(dev.id)).status_code == 403
assert client.put(f'/api/v1/status/{page_id}', json={'name': 'Y'},
headers=_token(dev.id)).status_code == 403
assert client.delete(f'/api/v1/status/{page_id}',
headers=_token(dev.id)).status_code == 403
assert client.post(f'/api/v1/status/{page_id}/components',
json={'name': 'c', 'check_type': 'http'},
headers=_token(dev.id)).status_code == 403
assert client.post(f'/api/v1/status/{page_id}/incidents', json={'title': 't'},
headers=_token(dev.id)).status_code == 403
@pytest.mark.parametrize('persona', ['viewer', 'member', 'foreign'])
def test_status_component_check_admin_only(app, client, scoping_rbac, install_dirs, persona):
"""POST /status/components/<id>/check is a write-ish mutation → admin-only
(relocated from test_api_authz_hardening.py, now against the extension)."""
plugin_service.install_builtin_extension(SLUG)
url = '/api/v1/status/components/999999/check'
assert client.post(url, headers=getattr(scoping_rbac, persona)).status_code == 403, persona
assert client.post(url, headers=scoping_rbac.admin).status_code != 403
-79
View File
@@ -1,79 +0,0 @@
"""Unit tests for the tunnel broker's pure helpers (roadmap #7 / #11).
These import only app.services.tunnel_netutil (stdlib-only) so they prove
the subnet allocator, interface-name derivation and health classification
without needing a DB or connected agents.
"""
from app.services import tunnel_netutil as tn
def test_pick_subnet_first_free():
cidr, edge, priv = tn.pick_subnet([])
assert cidr == '10.88.0.0/24'
assert edge == '10.88.0.1'
assert priv == '10.88.0.2'
def test_pick_subnet_skips_used():
cidr, edge, priv = tn.pick_subnet(['10.88.0.0/24', '10.88.1.0/24'])
assert cidr == '10.88.2.0/24'
assert edge == '10.88.2.1'
assert priv == '10.88.2.2'
def test_pick_subnet_exhausted():
used = ['10.88.%d.0/24' % i for i in range(256)]
try:
tn.pick_subnet(used)
assert False, "expected RuntimeError on exhausted pool"
except RuntimeError:
pass
def test_interface_name_is_stable_and_kernel_valid():
name = tn.interface_name_for('3fa9c2b1-dead-beef-0000-111122223333')
assert name == 'skwg3fa9c2b1'
assert len(name) <= 15
assert name.replace('skwg', '').isalnum()
def test_derive_status():
now = 1_000_000
assert tn.derive_status(now - 10, now) == 'up'
assert tn.derive_status(now - 10_000, now) == 'degraded'
assert tn.derive_status(0, now) == 'pending'
assert tn.derive_status(now, now, interface_up=False) == 'down'
def test_validate_endpoint_host():
assert tn.validate_endpoint_host('203.0.113.5') is True
assert tn.validate_endpoint_host('10.0.0.5') is True # private edge OK
assert tn.validate_endpoint_host('127.0.0.1') is False # loopback
assert tn.validate_endpoint_host('0.0.0.0') is False # unspecified
assert tn.validate_endpoint_host('') is False
assert tn.validate_endpoint_host('not-an-ip') is False
def test_pick_listen_port():
assert tn.pick_listen_port([]) == 51820
assert tn.pick_listen_port([51820]) == 51821
assert tn.pick_listen_port([51820, 51821, 51823]) == 51822 # fills the gap
def test_pick_listen_port_exhausted():
used = list(range(tn.DEFAULT_LISTEN_PORT, tn.DEFAULT_LISTEN_PORT + tn.LISTEN_PORT_RANGE))
try:
tn.pick_listen_port(used)
assert False, "expected RuntimeError on exhausted port pool"
except RuntimeError:
pass
def test_diagnose_reachability():
assert tn.diagnose_reachability(0, None, False)['state'] == 'interface_down'
assert tn.diagnose_reachability(1_700_000_000, 999, True)['state'] == 'ok'
assert tn.diagnose_reachability(0, 5, True)['state'] == 'connecting'
d = tn.diagnose_reachability(0, 600, True)
assert d['state'] == 'no_handshake'
assert 'blocked' in d['hint'].lower()
-101
View File
@@ -1,101 +0,0 @@
"""Phase 3 #19: panel-authoritative tunnel reconcile.
Proves reconcile_server re-issues the right agent commands for each side
of a tunnel, by monkeypatching agent_registry.send_command to record calls
(no live agent needed).
"""
from app import db
from app.models.server import Server
from app.models.tunnel import Tunnel
from app.models.exposed_service import ExposedService
from app.services import agent_registry as ar_mod
from app.services.tunnel_broker_service import TunnelBrokerService
def _server(name, ip=None):
s = Server(name=name, ip_address=ip, status='online')
db.session.add(s)
db.session.commit()
return s
def _record_send(monkeypatch):
calls = []
def fake_send(server_id, action, params=None, user_id=None, timeout=30.0):
calls.append({'server_id': server_id, 'action': action, 'params': params or {}})
return {'success': True, 'data': {}}
monkeypatch.setattr(ar_mod.agent_registry, 'send_command', fake_send)
return calls
def _tunnel(edge, priv, iface, third):
t = Tunnel(
edge_server_id=edge.id, private_server_id=priv.id,
interface_name=iface, subnet='10.88.%d.0/24' % third,
edge_wg_ip='10.88.%d.1' % third, private_wg_ip='10.88.%d.2' % third,
listen_port=51820, edge_pubkey='EDGEPUB', private_pubkey='PRIVPUB',
status='up',
)
db.session.add(t)
db.session.commit()
return t
def test_reconcile_private_side(app, monkeypatch):
calls = _record_send(monkeypatch)
edge = _server('edge', ip='203.0.113.9')
priv = _server('home')
t = _tunnel(edge, priv, 'skwgrec01', 7)
db.session.add(ExposedService(tunnel_id=t.id, hostname='jelly.example.com', port=8096, status='published'))
db.session.commit()
res = TunnelBrokerService.reconcile_server(priv.id)
assert res and res[0]['ok']
actions = [c['action'] for c in calls]
assert 'wireguard:interface:up' in actions
assert 'wireguard:peer:set' in actions
assert 'wireguard:forward' in actions
peer = next(c['params'] for c in calls if c['action'] == 'wireguard:peer:set')
assert peer['public_key'] == 'EDGEPUB' # private peers with the edge
assert peer['endpoint'] == '203.0.113.9:51820' # dials the edge endpoint
assert peer['persistent_keepalive'] == 25
fwd = next(c['params'] for c in calls if c['action'] == 'wireguard:forward')
assert fwd['listen_port'] == 8096 and fwd['target_port'] == 8096
assert fwd['listen_ip'] == '10.88.7.2'
def test_reconcile_edge_side(app, monkeypatch):
calls = _record_send(monkeypatch)
edge = _server('edge2', ip='198.51.100.4')
priv = _server('home2')
_tunnel(edge, priv, 'skwgrec02', 8)
TunnelBrokerService.reconcile_server(edge.id)
actions = [c['action'] for c in calls]
assert 'wireguard:interface:up' in actions
assert 'wireguard:peer:set' in actions
assert 'firewall:allow_port' in actions # edge re-opens its UDP port
peer = next(c['params'] for c in calls if c['action'] == 'wireguard:peer:set')
assert peer['public_key'] == 'PRIVPUB' # edge peers with the private host
assert 'endpoint' not in peer # edge has no endpoint for the private peer
def test_reconcile_skips_errored_tunnels(app, monkeypatch):
calls = _record_send(monkeypatch)
edge = _server('edge3', ip='192.0.2.7')
priv = _server('home3')
t = _tunnel(edge, priv, 'skwgrec03', 9)
t.status = 'error'
db.session.commit()
res = TunnelBrokerService.reconcile_server(priv.id)
assert res == []
assert calls == []
@@ -0,0 +1,11 @@
"""ServerKit Cloud Provisioning extension backend package.
Extracted from core (plan 47): the cloud-provider blueprint + service ship here
so a fresh panel that never provisions cloud servers loads none of it. Mounted
under ``/api/v1/cloud`` via the manifest's ``url_prefix``. The CloudProvider /
CloudServer models stay in core (G2); this package imports them. Upgrades
re-acquire it automatically (``CONVERTED_BUILTIN_SLUGS``).
"""
from .cloud_provisioning import cloud_provisioning_bp
__all__ = ['cloud_provisioning_bp']
@@ -1,6 +1,6 @@
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required
from app.services.cloud_provisioning_service import CloudProvisioningService
from .cloud_provisioning_service import CloudProvisioningService
cloud_provisioning_bp = Blueprint('cloud_provisioning', __name__)
@@ -8,6 +8,8 @@
"category": "deployment",
"homepage": "https://github.com/jhd3197/ServerKit",
"permissions": ["network"],
"entry_point": "cloud_provisioning:cloud_provisioning_bp",
"url_prefix": "/api/v1/cloud",
"icon": "<path d=\"M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z\"/>",
"contributions": {
"tabs": [
@@ -0,0 +1,10 @@
"""ServerKit FTP extension backend package.
Extracted from core (plan 47): the FTP blueprint + service ship here so a fresh
panel that never touches FTP loads none of it. Mounted under ``/api/v1/ftp`` via
the manifest's ``url_prefix``; upgrades re-acquire it automatically
(``CONVERTED_BUILTIN_SLUGS``).
"""
from .ftp import ftp_bp
__all__ = ['ftp_bp']
@@ -2,8 +2,8 @@
from flask import Blueprint, request, jsonify
from ..middleware.rbac import admin_required, viewer_required
from ..services.ftp_service import FTPService
from app.middleware.rbac import admin_required, viewer_required
from .ftp_service import FTPService
ftp_bp = Blueprint('ftp', __name__)
@@ -8,6 +8,8 @@
"category": "utility",
"homepage": "https://github.com/jhd3197/ServerKit",
"permissions": ["shell", "filesystem"],
"entry_point": "ftp:ftp_bp",
"url_prefix": "/api/v1/ftp",
"icon": "<rect x=\"16\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\"/><rect x=\"2\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\"/><rect x=\"9\" y=\"2\" width=\"6\" height=\"6\" rx=\"1\"/><path d=\"M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3\"/><path d=\"M12 12V8\"/>",
"contributions": {
"tabs": [
@@ -0,0 +1,13 @@
"""ServerKit Remote Access extension backend package (WireGuard tunnels).
Extracted from core (plan 47): the tunnels blueprint + broker/publish/netutil
services ship here so a fresh panel that never exposes a NAT'd service loads none
of it. Mounted under ``/api/v1/tunnels`` via the manifest's ``url_prefix``. The
Tunnel / ExposedService models stay in core (G2); this package imports them.
Core's agent gateway reaches TunnelBrokerService.schedule_reconcile through
``plugin_service.get_installed_extension_attr`` only when the extension is
installed. Upgrades re-acquire it automatically (``CONVERTED_BUILTIN_SLUGS``).
"""
from .tunnels import tunnels_bp
__all__ = ['tunnels_bp']
@@ -14,7 +14,7 @@ from app import db
from app.models.server import Server
from app.models.tunnel import Tunnel
from app.services.agent_registry import agent_registry
from app.services import tunnel_netutil
from . import tunnel_netutil
logger = logging.getLogger(__name__)
@@ -9,8 +9,8 @@ from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.models.tunnel import Tunnel
from app.services.tunnel_broker_service import TunnelBrokerService, TunnelBrokerError
from app.services.tunnel_publish_service import TunnelPublishService, TunnelPublishError
from .tunnel_broker_service import TunnelBrokerService, TunnelBrokerError
from .tunnel_publish_service import TunnelPublishService, TunnelPublishError
from app.middleware.rbac import developer_required
tunnels_bp = Blueprint('tunnels', __name__)
@@ -8,6 +8,8 @@
"category": "integration",
"homepage": "https://github.com/jhd3197/ServerKit",
"permissions": ["network"],
"entry_point": "tunnels:tunnels_bp",
"url_prefix": "/api/v1/tunnels",
"icon": "<rect x=\"16\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\"/><rect x=\"2\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\"/><rect x=\"9\" y=\"2\" width=\"6\" height=\"6\" rx=\"1\"/><path d=\"M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3\"/><path d=\"M12 12V8\"/>",
"contributions": {
"tabs": [
@@ -0,0 +1,16 @@
"""ServerKit Status Pages extension backend package.
Extracted from core (plan 47): the status-pages blueprint (public status/badge
routes + authenticated management API) and its service ship here so a fresh panel
that never publishes a status page loads none of it. Mounted under
``/api/v1/status`` via the manifest's ``url_prefix``; the public /status/<slug>
and /status/badge/<slug> pages work whenever the extension is installed. The
StatusPage / StatusComponent / StatusIncident / HealthCheck models stay in core
(G2). Core's WordPress health-check job reaches
``StatusPageService.sync_component_from_health`` through
``plugin_service.get_installed_extension_attr`` only when installed. Upgrades
re-acquire it automatically (``CONVERTED_BUILTIN_SLUGS``).
"""
from .status_pages import status_pages_bp
__all__ = ['status_pages_bp']
@@ -1,6 +1,6 @@
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required
from app.services.status_page_service import StatusPageService
from .status_page_service import StatusPageService
status_pages_bp = Blueprint('status_pages', __name__)
@@ -2,12 +2,14 @@
"name": "serverkit-status",
"display_name": "Status Pages",
"version": "1.0.0",
"description": "Public status pages backed by uptime monitors, with the management UI as a tab in the Observability group. The public /status/<slug> page itself stays core.",
"description": "Public status pages backed by uptime monitors, with the management UI as a tab in the Observability group. Installing the extension serves the public /status/<slug> and /status/badge/<slug> pages too.",
"author": "ServerKit",
"license": "MIT",
"category": "monitoring",
"homepage": "https://github.com/jhd3197/ServerKit",
"permissions": [],
"entry_point": "status_pages:status_pages_bp",
"url_prefix": "/api/v1/status",
"icon": "<circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20\"/><path d=\"M2 12h20\"/>",
"contributions": {
"tabs": [
+27
View File
@@ -448,3 +448,30 @@ Proven with `serverkit-git`; automated by the one-shot upgrade auto-install
Existing panels auto-install converted builtins once on upgrade (a marker in
settings) so nothing disappears; fresh installs see them in the Marketplace.
### Second speed — moving the backend out too (plan 47)
Once a converted page has settled as a frontend-only extension, its backend
blueprint + service can leave core:
1. `git mv backend/app/api/<feature>.py builtin-extensions/<slug>/backend/<module>.py`
and the same for its service(s). Rewrite imports: **core** imports stay
absolute (`from app.middleware.rbac import …`), **sibling** extension modules
become relative (`from .<sibling> import …`).
2. Add `backend/__init__.py` exposing the blueprint, and set the manifest's
`entry_point` (`"<module>:<bp>"`) + `url_prefix` (**unchanged** so frontend
API clients don't move).
3. Deregister it from `backend/app/__init__.py` (drop the import + `register_blueprint`).
4. Keep any **model** used by the extension in `backend/app/models/` (extensions
can't run migrations — G2). The extension imports it.
5. If a **core** code path calls into the now-extracted service (a job handler,
the agent gateway), reach it through
`plugin_service.get_installed_extension_attr(slug, module, attr)` so it
no-ops cleanly when the extension is absent.
6. Ensure the slug is in `extension_migration.CONVERTED_BUILTIN_SLUGS`. On an
upgraded panel that installed the extension **frontend-only** (its API used
to come from core), `run_backend_acquisition()` force-reinstalls once to
re-acquire the now-extracted backend — otherwise the API would vanish.
Landed this way: `serverkit-ftp`, `serverkit-cloud-provision`,
`serverkit-remote-access`, `serverkit-status`.
+6 -1
View File
@@ -429,9 +429,14 @@ function AppRoutes() {
<Route path="backups/:tab" element={<Backups />} />
</Route>
<Route path="cron" element={<CronJobs />} />
<Route element={<TabGroupLayout tabs={SECURITY_TABS} />}>
{/* Security tab group. groupId="security" (plan 47 Phase 3a) lets
extracted security features (fail2ban, clamav, …) contribute
tabs + group-nested routes via their manifests, so they return
as tabs in the place users know. */}
<Route element={<TabGroupLayout tabs={SECURITY_TABS} groupId="security" />}>
<Route path="security" element={<Security />} />
<Route path="security/:tab" element={<Security />} />
{groupRoutes.security}
</Route>
{/* Email routes are gated by the Email module toggle
(Settings → Modules); disabled ⇒ redirect to the dashboard. */}
@@ -1,6 +1,8 @@
import { useState, useEffect } from 'react';
import { useResourceTier } from '../../contexts/ResourceTierContext';
import { Sparkles } from 'lucide-react';
import { Sparkles, Check, Loader, AlertTriangle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import api from '../../services/api';
const USE_CASE_LABELS = {
wordpress: 'WordPress Sites',
@@ -9,35 +11,79 @@ const USE_CASE_LABELS = {
devops: 'DevOps & Monitoring',
};
const RECOMMENDATIONS = {
wordpress: ['WordPress', 'Nginx Proxy Manager', 'phpMyAdmin'],
'web-apps': ['Node.js App', 'Python App', 'Portainer'],
'self-hosted': ['Nextcloud', 'Vaultwarden', 'Wiki.js'],
devops: ['Grafana', 'Prometheus', 'Portainer'],
};
const DEFAULT_RECOMMENDATIONS = ['Portainer', 'Uptime Kuma', 'Nginx Proxy Manager'];
function getRecommendations(useCases) {
if (!useCases || useCases.length === 0) return DEFAULT_RECOMMENDATIONS;
const seen = new Set();
const result = [];
for (const uc of useCases) {
const items = RECOMMENDATIONS[uc] || [];
for (const item of items) {
if (!seen.has(item)) {
seen.add(item);
result.push(item);
}
}
}
return result.length > 0 ? result.slice(0, 4) : DEFAULT_RECOMMENDATIONS;
}
const SetupStepSummary = ({ accountInfo, useCases, onFinish }) => {
const { tier, specs, loading } = useResourceTier();
const recommendations = getRecommendations(useCases);
// Recommended extensions (real slugs from the backend), the checked set, and
// per-slug install status shown while finishing.
const [recommendations, setRecommendations] = useState([]);
const [recsLoading, setRecsLoading] = useState(true);
const [checked, setChecked] = useState(() => new Set());
const [installing, setInstalling] = useState(false);
const [installState, setInstallState] = useState({}); // slug -> 'installing'|'done'|'error'
useEffect(() => {
let active = true;
setRecsLoading(true);
api.getRecommendedExtensions(useCases)
.then((res) => {
if (!active) return;
const recs = res?.recommendations || [];
setRecommendations(recs);
// Default: everything checked ("lean" = uncheck what you don't want)
setChecked(new Set(recs.filter((r) => !r.installed).map((r) => r.slug)));
})
.catch(() => {
if (active) setRecommendations([]);
})
.finally(() => {
if (active) setRecsLoading(false);
});
return () => {
active = false;
};
}, [useCases]);
function toggle(slug) {
setChecked((prev) => {
const next = new Set(prev);
if (next.has(slug)) next.delete(slug);
else next.add(slug);
return next;
});
}
async function handleFinish() {
// Install the checked (not-already-installed) extensions, source-aware,
// one at a time with per-item progress. Fail-soft: an install failure is
// surfaced but never blocks completing onboarding.
const toInstall = recommendations.filter(
(r) => checked.has(r.slug) && !r.installed
);
const installedSlugs = recommendations
.filter((r) => r.installed && checked.has(r.slug))
.map((r) => r.slug);
if (toInstall.length > 0) {
setInstalling(true);
for (const rec of toInstall) {
setInstallState((s) => ({ ...s, [rec.slug]: 'installing' }));
try {
if (rec.source === 'registry') {
await api.installRegistryExtension(rec.slug);
} else {
await api.installBuiltinExtension(rec.slug);
}
installedSlugs.push(rec.slug);
setInstallState((s) => ({ ...s, [rec.slug]: 'done' }));
} catch {
setInstallState((s) => ({ ...s, [rec.slug]: 'error' }));
}
}
}
await onFinish(installedSlugs);
}
function formatSpecs() {
if (!specs) return 'Detecting...';
@@ -53,6 +99,25 @@ const SetupStepSummary = ({ accountInfo, useCases, onFinish }) => {
return tier.charAt(0).toUpperCase() + tier.slice(1);
}
const anyError = Object.values(installState).some((v) => v === 'error');
function renderRecStatus(rec) {
if (rec.installed) {
return <span className="recommendation-item__status recommendation-item__status--installed">Installed</span>;
}
const state = installState[rec.slug];
if (state === 'installing') {
return <Loader size={15} className="recommendation-item__spinner" />;
}
if (state === 'done') {
return <Check size={15} className="recommendation-item__status--done" />;
}
if (state === 'error') {
return <AlertTriangle size={15} className="recommendation-item__status--error" />;
}
return null;
}
return (
<div className="wizard-step">
<h2 className="wizard-step-title">You&apos;re all set</h2>
@@ -102,24 +167,66 @@ const SetupStepSummary = ({ accountInfo, useCases, onFinish }) => {
</div>
</div>
<div className="summary-section">
<div className="summary-section-title">
<Sparkles size={14} style={{ display: 'inline', verticalAlign: 'middle', marginRight: 6 }} />
Recommended for you
{(recsLoading || recommendations.length > 0) && (
<div className="summary-section">
<div className="summary-section-title">
<Sparkles size={14} style={{ display: 'inline', verticalAlign: 'middle', marginRight: 6 }} />
Recommended for you
</div>
{recsLoading ? (
<div className="summary-row">
<span className="summary-label">Loading recommendations...</span>
</div>
) : (
<>
<p className="recommendation-hint">
We&apos;ll install what you check. Uncheck anything you don&apos;t
need — you can add it later from Extensions.
</p>
<div className="recommendation-list">
{recommendations.map((rec) => (
<label key={rec.slug} className="recommendation-item">
<input
type="checkbox"
className="recommendation-item__check"
checked={rec.installed || checked.has(rec.slug)}
disabled={rec.installed || installing}
onChange={() => toggle(rec.slug)}
/>
<span className="recommendation-item__body">
<span className="recommendation-item__name">
{rec.display_name}
</span>
{rec.description && (
<span className="recommendation-item__desc">
{rec.description}
</span>
)}
</span>
{renderRecStatus(rec)}
</label>
))}
</div>
{anyError && (
<p className="recommendation-error">
Some extensions couldn&apos;t be installed. You can retry
from the <a href="/extensions">Extensions</a> page.
</p>
)}
</>
)}
</div>
<div className="recommendation-chips">
{recommendations.map((name) => (
<span key={name} className="recommendation-chip">
{name}
</span>
))}
</div>
</div>
)}
</div>
<div className="wizard-nav" style={{ borderTop: 'none', marginTop: 0, paddingTop: 0 }}>
<button type="button" className="btn-wizard-next" onClick={onFinish}>
Go to Dashboard
<button
type="button"
className="btn-wizard-next"
onClick={handleFinish}
disabled={installing}
>
{installing ? 'Setting up...' : 'Go to Dashboard'}
</button>
</div>
</div>
+2 -2
View File
@@ -116,8 +116,8 @@ export function AuthProvider({ children }) {
return data;
}
async function completeOnboarding(useCases) {
await api.completeOnboarding(useCases);
async function completeOnboarding(useCases, installedExtensions = []) {
await api.completeOnboarding(useCases, installedExtensions);
// Tailor the initial sidebar to the use cases picked during setup, so a
// fresh install opens focused instead of showing every item. Best-effort:
// if it fails, the sidebar just falls back to the "Recommended" default.
+2 -2
View File
@@ -47,8 +47,8 @@ const Setup = () => {
setCurrentStep(4);
}
async function handleFinish() {
await completeOnboarding(useCases);
async function handleFinish(installedExtensions = []) {
await completeOnboarding(useCases, installedExtensions);
navigate('/');
}
@@ -10,6 +10,8 @@
"permissions": [
"network"
],
"entry_point": "cloud_provisioning:cloud_provisioning_bp",
"url_prefix": "/api/v1/cloud",
"icon": "<path d=\"M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z\"/>",
"contributions": {
"tabs": [
@@ -11,6 +11,8 @@
"shell",
"filesystem"
],
"entry_point": "ftp:ftp_bp",
"url_prefix": "/api/v1/ftp",
"icon": "<rect x=\"16\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\"/><rect x=\"2\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\"/><rect x=\"9\" y=\"2\" width=\"6\" height=\"6\" rx=\"1\"/><path d=\"M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3\"/><path d=\"M12 12V8\"/>",
"contributions": {
"tabs": [
@@ -10,6 +10,8 @@
"permissions": [
"network"
],
"entry_point": "tunnels:tunnels_bp",
"url_prefix": "/api/v1/tunnels",
"icon": "<rect x=\"16\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\"/><rect x=\"2\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\"/><rect x=\"9\" y=\"2\" width=\"6\" height=\"6\" rx=\"1\"/><path d=\"M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3\"/><path d=\"M12 12V8\"/>",
"contributions": {
"tabs": [
@@ -2,12 +2,14 @@
"name": "serverkit-status",
"display_name": "Status Pages",
"version": "1.0.0",
"description": "Public status pages backed by uptime monitors, with the management UI as a tab in the Observability group. The public /status/<slug> page itself stays core.",
"description": "Public status pages backed by uptime monitors, with the management UI as a tab in the Observability group. Installing the extension serves the public /status/<slug> and /status/badge/<slug> pages too.",
"author": "ServerKit",
"license": "MIT",
"category": "monitoring",
"homepage": "https://github.com/jhd3197/ServerKit",
"permissions": [],
"entry_point": "status_pages:status_pages_bp",
"url_prefix": "/api/v1/status",
"icon": "<circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20\"/><path d=\"M2 12h20\"/>",
"contributions": {
"tabs": [
+2 -2
View File
@@ -25,10 +25,10 @@ export async function register(email, username, password, inviteToken) {
return data;
}
export async function completeOnboarding(useCases) {
export async function completeOnboarding(useCases, installedExtensions = []) {
return this.request('/auth/complete-onboarding', {
method: 'POST',
body: { use_cases: useCases },
body: { use_cases: useCases, installed_extensions: installedExtensions },
});
}
+9
View File
@@ -103,6 +103,15 @@ export async function getBuiltinExtensions() {
return this.request('/plugins/builtin');
}
// Setup wizard: extensions recommended for the selected onboarding use cases.
// Returns { recommendations: [{ slug, display_name, description, category,
// source: 'builtin'|'registry', installed, status }] }.
export async function getRecommendedExtensions(useCases = []) {
const list = (useCases || []).filter(Boolean);
const q = list.length ? `?use_cases=${encodeURIComponent(list.join(','))}` : '';
return this.request(`/plugins/recommendations${q}`);
}
// One-click install for a bundled extension by slug.
export async function installBuiltinExtension(slug) {
return this.request(`/plugins/builtin/${encodeURIComponent(slug)}/install`, {
@@ -385,6 +385,97 @@
color: $text-primary;
}
// --------------------------------------------
// Recommended extensions — install checkboxes (plan 47)
// --------------------------------------------
.recommendation-hint {
margin: 0 0 $space-3;
font-size: $font-size-sm;
color: $text-secondary;
}
.recommendation-list {
display: flex;
flex-direction: column;
gap: $space-2;
}
.recommendation-item {
display: flex;
align-items: flex-start;
gap: $space-3;
padding: $space-3 $space-4;
background: $bg-secondary;
border: 1px solid $border-default;
border-radius: $radius-lg;
cursor: pointer;
&:hover {
border-color: $primary-color;
}
&__check {
margin-top: 3px;
flex-shrink: 0;
cursor: pointer;
}
&__body {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
&__name {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $text-primary;
}
&__desc {
font-size: $font-size-xs;
color: $text-secondary;
}
&__spinner {
color: $text-secondary;
animation: spin 1s linear infinite;
flex-shrink: 0;
}
&__status {
font-size: $font-size-xs;
flex-shrink: 0;
&--installed {
color: $text-secondary;
}
&--done {
color: $success-color;
flex-shrink: 0;
}
&--error {
color: $danger-color;
flex-shrink: 0;
}
}
}
.recommendation-error {
margin: $space-3 0 0;
font-size: $font-size-sm;
color: $danger-color;
a {
color: $primary-color;
text-decoration: underline;
}
}
// --------------------------------------------
// Navigation buttons
// --------------------------------------------