mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-02 01:57:58 +09:00
Merge plan-47 lean-core (wizard installs + 4 backend extractions + security tab-group)
This commit is contained in:
commit
b7911026be
51 files changed
+1410
-309
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)))
|
||||
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
2588
|
||||
2623
|
||||
@@ -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)
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
-1
@@ -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__)
|
||||
|
||||
File renamed without changes.
@@ -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__)
|
||||
|
||||
File renamed without changes.
@@ -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']
|
||||
+1
-1
@@ -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__)
|
||||
|
||||
File renamed without changes.
File renamed without changes.
+2
-2
@@ -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']
|
||||
File renamed without changes.
+1
-1
@@ -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": [
|
||||
|
||||
@@ -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`.
|
||||
@@ -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'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'll install what you check. Uncheck anything you don'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'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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
// --------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user