Merge pull request #164 from jhd3197/dev

Harden, fix and clean up the panel after a full code audit
This commit is contained in:
Juan Denis authored and GitHub committed 2026-10-11 15:48:07 -04:00
commit c4fed2b877
730 files changed
+10428 -11203

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
# Dependency update PRs target dev (main only accepts promotions from dev).
# Grouped and weekly so they arrive as a few reviewable PRs, not a stream.
version: 2
updates:
- package-ecosystem: pip
directory: /backend
target-branch: dev
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
backend:
patterns: ["*"]
update-types: [minor, patch]
- package-ecosystem: npm
directory: /frontend
target-branch: dev
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
frontend:
patterns: ["*"]
update-types: [minor, patch]
- package-ecosystem: github-actions
directory: /
target-branch: dev
schedule:
interval: monthly
groups:
actions:
patterns: ["*"]
+10 -1
View File
@@ -15,7 +15,7 @@ on:
- 'backend/**'
- '.github/workflows/backend-ci.yml'
pull_request:
branches: [main]
branches: [main, dev]
paths:
- 'backend/**'
- '.github/workflows/backend-ci.yml'
@@ -48,6 +48,15 @@ jobs:
# Lowering the floor requires editing BASELINE_COUNT in the same commit.
working-directory: backend
run: python tests/check_test_count.py
- name: Undefined names
# A name used before it exists only fails when that line runs, so a
# rarely-taken branch ships broken: two routes that always 500'd on a
# missing import, and a Stop button that crashed on a shadowing local
# import. Pyflakes catches both statically in about a second.
working-directory: backend
run: |
pip install ruff==0.14.14
ruff check --select F821,F823 app tests
# Sharded because this job WAS the entire wait: 21m50s of a ~22m pipeline,
# while every other workflow finished inside a minute. Splitting the suite
+1 -1
View File
@@ -15,7 +15,7 @@ on:
- 'scripts/sync-builtin-frontends.mjs'
- '.github/workflows/extensions-ci.yml'
pull_request:
branches: [main]
branches: [main, dev]
paths:
- 'builtin-extensions/**'
- 'frontend/src/plugins/**'
+13 -1
View File
@@ -16,7 +16,7 @@ on:
- 'scripts/check-html-sinks.mjs'
- '.github/workflows/frontend-ci.yml'
pull_request:
branches: [main]
branches: [main, dev]
paths:
- 'frontend/**'
- 'backend/app/**'
@@ -68,6 +68,18 @@ jobs:
- name: Backup request and form browser regressions
working-directory: frontend
run: npm run test:backups
- name: SVG sanitizer browser regressions
working-directory: frontend
run: npm run test:sanitize-svg
- name: Terminal log viewer browser regressions
working-directory: frontend
run: npm run test:terminal-logs
- name: Docker tabs browser regressions
working-directory: frontend
run: npm run test:docker-tabs
- name: File Manager browser regressions
working-directory: frontend
run: npm run test:file-manager
- name: Upload Settings screenshots
if: always()
uses: actions/upload-artifact@v4
+1 -1
View File
@@ -18,7 +18,7 @@ on:
- 'scripts/test/test_measure_runtime.py'
- '.github/workflows/measurements-ci.yml'
pull_request:
branches: [main]
branches: [main, dev]
paths:
- 'README.md'
- 'docs/README*.md'
+1 -1
View File
@@ -26,7 +26,7 @@ on:
- '.github/workflows/version-bump.yml'
- '.github/workflows/scripts-ci.yml'
pull_request:
branches: [main]
branches: [main, dev]
paths:
- 'scripts/**'
- 'templates/**'
+1 -1
View File
@@ -12,7 +12,7 @@ on:
- 'scripts/security/**'
- '.github/workflows/security-scan.yml'
pull_request:
branches: [main]
branches: [main, dev]
paths:
- 'backend/**'
- 'builtin-extensions/**'
+1 -1
View File
@@ -9,7 +9,7 @@ on:
- 'backend/tests/test_utils_system*.py'
- '.github/workflows/test-system-utils.yml'
pull_request:
branches: [main]
branches: [main, dev]
paths:
- 'backend/app/utils/system.py'
- 'backend/app/services/**'
+5 -6
View File
@@ -44,7 +44,7 @@ RUN npm run build
# Stage 2: Python dependency builder
#
# The compiler toolchain (gcc, *-dev headers) is needed to build wheels for
# gevent / cryptography / lxml on platforms without a prebuilt wheel, but it is
# cryptography / lxml / psutil on platforms without a prebuilt wheel, but it is
# ~280 MB and has no business in a running panel. Building into a self-contained
# venv here lets the runtime stage take the result and leave the toolchain behind.
FROM python:3.11-slim-bookworm AS python-builder
@@ -55,8 +55,6 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
python3-dev \
# Required for gevent
libevent-dev \
# Required for cryptography
libffi-dev \
libssl-dev \
@@ -73,7 +71,7 @@ RUN pip install --no-cache-dir --upgrade pip \
# surfaces as a failed image build, never as a half-installed panel on a host.
#
# 1. Bundled test suites. Third-party packages ship their own pytest trees
# (gevent and passlib are the big ones) that no running panel imports.
# (passlib is the big one) that no running panel imports.
# 2. botocore API models. botocore carries JSON models for 400+ AWS services
# (~15 MB); ServerKit constructs exactly three clients — s3
# (storage_provider_service), route53 (dns_provider_service) and ses
@@ -180,8 +178,9 @@ USER serverkit
# Expose port
EXPOSE 5000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
# Health check. Boot runs schema sync, migrations and settings seeding; the
# 40s grace matches docker-compose.yml so a slow first boot is not unhealthy.
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f "http://localhost:${SERVERKIT_BACKEND_PORT:-5000}/api/v1/system/health" || exit 1
# Working directory for the backend
+1 -1
View File
@@ -1 +1 @@
1.11.19
1.11.20
-71
View File
@@ -1,71 +0,0 @@
# Backend Dockerfile for ServerKit
#
# NOTE: A containerized backend CANNOT manage the host (no host Docker daemon,
# nginx, systemctl, or real /var/serverkit/apps). The canonical deployment runs
# the backend on the host via systemd (see install.sh + docker-compose.yml,
# which is frontend-only). This image is only for local/dev or all-in-one demos.
FROM python:3.11-slim-bookworm
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
FLASK_ENV=production \
SERVERKIT_BACKEND_PORT=5000
# Set working directory
WORKDIR /app
# Install system dependencies including ClamAV for security scanning
RUN apt-get update && apt-get install -y --no-install-recommends \
# Build dependencies
gcc \
python3-dev \
libpq-dev \
libffi-dev \
libssl-dev \
libevent-dev \
# ClamAV antivirus
clamav \
clamav-daemon \
clamav-freshclam \
# Utilities
curl \
procps \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# Create directories for ServerKit
RUN mkdir -p /etc/serverkit /var/serverkit/apps /app/logs /var/quarantine /app/instance /var/backups/serverkit
# Copy requirements first for caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt \
&& pip install --no-cache-dir gunicorn gevent gevent-websocket
# Copy application code
COPY . .
# Create non-root user and set permissions. /var/serverkit must be owned by
# appuser too, otherwise the (dev/demo) backend cannot create app directories
# under /var/serverkit/apps and fails with EACCES.
RUN useradd --create-home appuser \
&& chown -R appuser:appuser /app \
&& chown -R appuser:appuser /app/instance \
&& chown -R appuser:appuser /etc/serverkit \
&& chown -R appuser:appuser /var/serverkit \
&& chown -R appuser:appuser /var/quarantine \
&& chown -R appuser:appuser /var/backups/serverkit
USER appuser
# Expose port
EXPOSE 5000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f "http://localhost:${SERVERKIT_BACKEND_PORT:-5000}/api/v1/system/health" || exit 1
# Run with gunicorn + gevent for WebSocket support
CMD ["sh", "-c", "exec gunicorn -k geventwebsocket.gunicorn.workers.GeventWebSocketWorker -w 1 -b 0.0.0.0:${SERVERKIT_BACKEND_PORT:-5000} --timeout 120 --access-logfile - --error-logfile - run:app"]
+33 -15
View File
@@ -260,7 +260,7 @@ def create_app(config_name=None):
SettingsService.initialize_defaults()
SettingsService.migrate_legacy_roles()
if not app.config.get('TESTING'):
# Size-aware retention defaults, once per install (plan 85 §C).
# Size-aware retention defaults, once per install.
# Skipped under test: the result would depend on the test host's disk.
try:
from app.services import storage_profile_service
@@ -275,19 +275,13 @@ def create_app(config_name=None):
from app.services.storage_provider_service import StorageProviderService
n_dns = DNSProviderService.encrypt_legacy_secrets()
n_store = StorageProviderService.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.
from app.services.dns_zone_service import DNSZoneService
n_zones = DNSZoneService.link_legacy_zones()
# Fold a legacy single-row email relay config into the unified
# EmailProviderConnection table (§6); idempotent, best-effort.
# EmailProviderConnection table; idempotent, best-effort.
try:
from app.services.email_relay_service import EmailRelayService
EmailRelayService.migrate_legacy_config()
@@ -295,18 +289,18 @@ def create_app(config_name=None):
import logging as _logging
_logging.getLogger(__name__).warning(
f'Email relay legacy migration skipped: {_relay_exc}')
if n_dns or n_store or n_cloud or n_settings or n_zones:
if n_dns or n_store or n_settings or n_zones:
import logging as _logging
_logging.getLogger(__name__).info(
f'Encrypted legacy secrets at rest: {n_dns} DNS provider(s), '
f'{n_store} storage field(s), {n_cloud} cloud provider(s), '
f'{n_store} storage field(s), '
f'{n_settings} system setting(s); linked {n_zones} DNS zone(s) '
f'to a connection')
except Exception as e:
import logging as _logging
_logging.getLogger(__name__).warning(f'Legacy secret encryption skipped: {e}')
# Record this boot's hardware and compare it to the last one (plan 74).
# Record this boot's hardware and compare it to the last one.
# Boot is the right hook: a VPS resize requires a power-off, so the
# change and the restart are the same event — and the in-memory spec
# cache that used to be the only "before" value dies with the process.
@@ -370,7 +364,7 @@ def create_app(config_name=None):
import logging
logging.getLogger(__name__).warning(f'Extension auto-install: {e}')
# Upgrade parity for the extracted security suite (plan 47 Ph3b-4):
# Upgrade parity for the extracted security suite:
# a panel that was actually using fail2ban/clamav/lynis/auto-updates/
# image scanning gets the matching registry extension once its entry
# is published. Fail-soft, retries per boot until then. Best-effort.
@@ -382,7 +376,7 @@ def create_app(config_name=None):
logging.getLogger(__name__).warning(f'Security-suite 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 first shipped frontend-only, 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
@@ -407,6 +401,16 @@ def create_app(config_name=None):
from app.utils.env import env_bool
_skip_background = env_bool('SERVERKIT_SKIP_BACKGROUND') or _cli_one_shot
if not _skip_background:
# Before any consumer polls: release what the previous process
# left in flight, or it stays stranded forever.
if not app.config.get('TESTING'):
from app.jobs.service import JobService
try:
JobService.recover_interrupted()
except Exception:
import logging
logging.getLogger(__name__).exception('Interrupted-job recovery failed')
# Start metrics history collection in background
from app.services.metrics_history_service import MetricsHistoryService
if not MetricsHistoryService.is_running():
@@ -440,7 +444,7 @@ def create_app(config_name=None):
DeploymentJobService.register_jobs()
from app.services.recipe_execution_service import RecipeExecutionService
RecipeExecutionService.register_jobs()
# WorkflowEngine.register_jobs() removed in plan 45 Phase 4 (engine retired).
# WorkflowEngine.register_jobs() removed (engine retired).
from app.services.backup_service import BackupService
BackupService.register_jobs()
from app.services.backup_policy_service import BackupPolicyService
@@ -453,7 +457,7 @@ def create_app(config_name=None):
MetadataGuardService.register_jobs()
if not app.config.get('TESTING'):
MetadataGuardService.ensure() # converge the metadata egress rule (no-op when unsupported)
# Bound the log files the panel writes or causes (plan 85 A5).
# Bound the log files the panel writes or causes.
from app.services import log_limits_service
log_limits_service.converge()
from app.services.speed_test_service import SpeedTestService
@@ -481,6 +485,20 @@ def create_app(config_name=None):
BandwidthService.register_jobs()
start_job_system(app, seed=seed_builtin_schedules)
# Threshold alerting and the uptime sampler are switched on from
# the UI; without this they silently stop at the next restart
# while their status still reads "enabled".
# Best effort: a resume failure must never stop the panel booting.
if not app.config.get('TESTING'):
from app.services.monitoring_service import MonitoringService
from app.services.uptime_service import UptimeService
for resume in (lambda: MonitoringService.resume_if_enabled(app),
UptimeService.resume_if_enabled):
try:
resume()
except Exception: # noqa: BLE001
app.logger.exception('Could not resume a background monitor at boot')
# Resume the embedded agent when this panel is linked to a master
# ServerKit panel (ServerKit-to-ServerKit peering).
from app.services.linked_panel_service import LinkedPanelService
+3 -3
View File
@@ -20,7 +20,7 @@ from app.models.server import Server
logger = logging.getLogger(__name__)
# The per-IP auth rate limiter is transport-neutral state and lives with the
# shared handshake door in agent_registry (plan 77 A2). Re-exported here for
# shared handshake door in agent_registry. Re-exported here for
# callers/tests that historically reached it via the gateway module.
from app.services.agent_registry import ( # noqa: F401
_AUTH_RATE_LIMIT,
@@ -53,8 +53,8 @@ class AgentNamespace(Namespace):
"""
Handle agent authentication.
Thin adapter over agent_registry.authenticate_and_register (plan 77
A2) — the handshake sequence itself is transport-neutral and shared
Thin adapter over agent_registry.authenticate_and_register — the
handshake sequence itself is transport-neutral and shared
with the long-poll /connect endpoint; this handler only translates
the outcome into socket emits/disconnects.
+24
View File
@@ -0,0 +1,24 @@
"""Per-app access for routes: the one way a view loads "the app this caller
may touch". Raises the typed 404/403 the global error handler renders."""
from flask import request
from app.services.resource_grant_service import ResourceGrantService
from app.services.workspace_service import WorkspaceService
from app.middleware.rbac import get_current_user
def require_app(app_id, *, write=False, conceal=False):
"""The live application the current user may read, or edit with ``write``.
``conceal`` answers a denied caller with the same 404 as a missing app.
"""
return ResourceGrantService.get_app_for(
get_current_user(), app_id, write=write, conceal=conceal)
def visible_workspace_ids(user):
"""Workspace ids the caller may see, honouring the active workspace
context (``X-Workspace-Id`` header or ``workspace_id`` query param)."""
requested = request.headers.get('X-Workspace-Id') or request.args.get('workspace_id')
return WorkspaceService.visible_workspace_ids(
user, WorkspaceService.resolve_workspace_id(user, requested))
+22
View File
@@ -0,0 +1,22 @@
"""Request-body parsing for routes."""
from flask import request
from app.exceptions import ValidationError
def json_object(optional=False):
"""The request body as a dict, or a 400.
``request.get_json()`` hands back whatever JSON was sent, so a body of
``null``, ``[]`` or a bare string reached ``data.get(...)`` and crashed
the route into a 500. Flask already rejects a missing or non-JSON body
(415/400); with ``optional=True`` that case is an empty dict instead,
for action endpoints whose body is all optional settings.
"""
data = request.get_json(silent=True) if optional else request.get_json()
if data is None and optional:
return {}
if not isinstance(data, dict):
raise ValidationError('Request body must be a JSON object',
code='validation.body_not_object')
return data
+10 -9
View File
@@ -14,6 +14,7 @@ from app.services.settings_service import SettingsService
from app.services.permission_service import PermissionService
from app.utils.domain import detect_request_domain, canonical_origin, is_valid_canonical_domain
from app.exceptions import not_found
from app.api._request import json_object
admin_bp = Blueprint('admin', __name__)
@@ -36,7 +37,7 @@ def list_users():
@admin_required
def create_user():
"""Create a new user."""
data = request.get_json()
data = json_object()
current_user_id = get_jwt_identity()
if not data:
@@ -104,7 +105,7 @@ def get_user(user_id):
@admin_required
def update_user(user_id):
"""Update a user."""
data = request.get_json()
data = json_object()
current_user_id = get_jwt_identity()
user = User.query.get(user_id)
@@ -304,7 +305,7 @@ def get_settings():
@admin_required
def update_settings():
"""Update system settings."""
data = request.get_json()
data = json_object()
current_user_id = get_jwt_identity()
if not data:
@@ -346,7 +347,7 @@ def get_setting(key):
@admin_required
def update_setting(key):
"""Update a specific setting."""
data = request.get_json()
data = json_object()
current_user_id = get_jwt_identity()
if 'value' not in data:
@@ -391,7 +392,7 @@ def get_domain_detection():
@admin_required
def set_canonical_domain():
"""Persist the canonical panel domain and HTTPS flag."""
data = request.get_json() or {}
data = json_object()
domain = (data.get('domain') or '').strip().lower()
https_enabled = bool(data.get('https_enabled', False))
current_user_id = get_jwt_identity()
@@ -441,7 +442,7 @@ def sites_https_setup():
"""Create the wildcard DNS record + issue the wildcard cert for a base domain
(the default base when ``base`` is omitted)."""
from app.services.sites_https_service import SitesHttpsService
data = request.get_json() or {}
data = json_object()
result = SitesHttpsService.setup(data.get('provider_id'), email=data.get('email'),
base=data.get('base') or data.get('domain'))
return jsonify(result), 200 if result.get('success') else 400
@@ -452,7 +453,7 @@ def sites_https_setup():
def sites_base_domain_add():
"""Register a base domain managed sites can publish under."""
from app.services.site_base_domain_service import SiteBaseDomainService
data = request.get_json() or {}
data = json_object()
result = SiteBaseDomainService.add(
data.get('domain'),
dns_provider_config_id=data.get('provider_id') or data.get('dns_provider_config_id'),
@@ -485,7 +486,7 @@ def sites_base_domain_set_default(domain):
def sites_base_domain_update(domain):
"""Update a base domain's DNS mode (wildcard | per-site)."""
from app.services.site_base_domain_service import SiteBaseDomainService
data = request.get_json() or {}
data = json_object()
result = SiteBaseDomainService.update(domain, dns_mode=data.get('dns_mode'))
return jsonify(result), 200 if result.get('success') else 404
@@ -541,7 +542,7 @@ def get_user_permissions(user_id):
@admin_required
def update_user_permissions(user_id):
"""Update a user's custom permissions."""
data = request.get_json()
data = json_object()
current_user_id = get_jwt_identity()
if not data or 'permissions' not in data:
+4 -3
View File
@@ -4,6 +4,7 @@ from app.services.advanced_ssl_service import AdvancedSSLService
from app.services.audit_service import AuditService
from app.models.audit_log import AuditLog
from app.middleware.rbac import get_current_user
from app.api._request import json_object
advanced_ssl_bp = Blueprint('advanced_ssl', __name__)
@@ -21,7 +22,7 @@ def issue_wildcard():
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json() or {}
data = json_object()
domain = data.get('domain')
provider = data.get('dns_provider')
creds = data.get('credentials', {})
@@ -46,7 +47,7 @@ def issue_san():
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json() or {}
data = json_object()
domains = data.get('domains', [])
if not domains:
return jsonify({'error': 'domains list required'}), 400
@@ -66,7 +67,7 @@ def upload_cert():
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json() or {}
data = json_object()
domain = data.get('domain')
cert = data.get('certificate')
key = data.get('private_key')
+7 -6
View File
@@ -4,6 +4,7 @@ from app.services.agent_plugin_service import AgentPluginService
from app.services.audit_service import AuditService
from app.models.audit_log import AuditLog
from app.middleware.rbac import get_current_user
from app.api._request import json_object
agent_plugins_bp = Blueprint('agent_plugins', __name__)
@@ -32,7 +33,7 @@ def create_plugin():
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'Request body required'}), 400
@@ -62,7 +63,7 @@ def update_plugin(plugin_id):
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json()
data = json_object()
plugin = AgentPluginService.update_plugin(plugin_id, data)
if not plugin:
return jsonify({'error': 'Plugin not found'}), 404
@@ -96,7 +97,7 @@ def delete_plugin(plugin_id):
@agent_plugins_bp.route('/validate', methods=['POST'])
@jwt_required()
def validate_manifest():
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'Manifest data required'}), 400
errors = AgentPluginService.validate_manifest(data)
@@ -112,7 +113,7 @@ def install_plugin(plugin_id):
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json() or {}
data = json_object()
server_id = data.get('server_id')
if not server_id:
return jsonify({'error': 'server_id required'}), 400
@@ -140,7 +141,7 @@ def bulk_install_plugin(plugin_id):
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json() or {}
data = json_object()
server_ids = data.get('server_ids', [])
if not server_ids:
return jsonify({'error': 'server_ids required'}), 400
@@ -211,7 +212,7 @@ def update_install_config(install_id):
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json()
data = json_object()
if not data or 'config' not in data:
return jsonify({'error': 'config required'}), 400
+5 -4
View File
@@ -20,6 +20,7 @@ import logging
from flask import Blueprint, jsonify, request
from app.services.agent_registry import agent_registry
from app.api._request import json_object
logger = logging.getLogger(__name__)
@@ -28,7 +29,7 @@ agent_poll_bp = Blueprint('agent_poll', __name__)
def _client_ip():
# ProxyFix-corrected real client IP when TRUST_PROXY_HEADERS is on (plan 48),
# ProxyFix-corrected real client IP when TRUST_PROXY_HEADERS is on,
# so the shared per-IP auth throttle keys on the real agent, not nginx.
return request.remote_addr or 'unknown'
@@ -38,7 +39,7 @@ def connect():
"""Authenticate an agent and return a session_token bound to a
polling-mode ConnectedAgent.
Thin adapter over agent_registry.authenticate_and_register (plan 77 A2)
Thin adapter over agent_registry.authenticate_and_register
— the exact sequence the WS on_auth handler runs, minus the socket-room
bookkeeping; this endpoint only translates the outcome to HTTP."""
data = request.get_json(silent=True) or {}
@@ -86,7 +87,7 @@ def poll():
if not agent:
return jsonify({'error': 'invalid session'}), 401
body = request.get_json(silent=True) or {}
body = json_object(optional=True)
metrics = body.get('metrics') or {}
sysinfo = body.get('system_info')
caps = body.get('capabilities')
@@ -104,7 +105,7 @@ def poll():
list(caps.keys()) if isinstance(caps, dict) else None,
)
# Shared transport-neutral ingest (plan 77 A1/A2): capability
# Shared transport-neutral ingest: capability
# side-effects (e.g. tunnel reconcile) fire exactly as on the WS path.
agent_registry.ingest_agent_state(agent, metrics=metrics, sysinfo=sysinfo,
capabilities=caps)
+8 -7
View File
@@ -44,6 +44,7 @@ from app.services.ai_tool_registry import ai_tool_registry
from app.error_reporting import unexpected_response
from app.exceptions import ApplicationError, DependencyUnavailableError, ValidationError
from app.services import ai_management, ai_usage, ai_runtime
from app.api._request import json_object
logger = logging.getLogger(__name__)
ai_bp = Blueprint('ai', __name__)
@@ -275,7 +276,7 @@ def update_settings():
@admin_required
def test_settings():
from app.services.settings_service import SettingsService
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
provider = (data.get('provider') or SettingsService.get('ai_provider', '')).strip()
model = (data.get('model') or SettingsService.get('ai_model', '')).strip()
endpoint = data.get('endpoint')
@@ -389,7 +390,7 @@ def list_conversations():
@jwt_required()
def create_conversation():
user = get_current_user()
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
mode = data.get('mode') or AiConversation.MODE_ASSISTANT
try:
row = _load_or_create(None, user, mode, data.get('connection_id'), data.get('model'),
@@ -418,7 +419,7 @@ def rename_conversation(conversation_id):
row = _owned_conversation(conversation_id, user)
if row is None:
return jsonify({'error': 'Conversation not found'}), 404
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
if 'title' in data:
row.title = (data['title'] or '').strip()[:256] or row.title
db.session.commit()
@@ -454,7 +455,7 @@ def chat():
user = get_current_user()
if not ai_service.is_configured():
return jsonify({'error': 'AI assistant is not configured'}), 503
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
invalid = _validate_chat_data(data)
if invalid:
raise ValidationError(invalid)
@@ -541,7 +542,7 @@ def chat_stream():
user = get_current_user()
if not ai_service.is_configured():
return jsonify({'error': 'AI assistant is not configured'}), 503
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
invalid = _validate_chat_data(data)
if invalid:
raise ValidationError(invalid)
@@ -732,7 +733,7 @@ def chat_stream():
@jwt_required()
def chat_confirm():
user = get_current_user()
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
conversation_id = data.get('conversation_id')
token = data.get('action_token')
decision = 'approve' if data.get('decision') == 'approve' else 'deny'
@@ -755,7 +756,7 @@ def chat_confirm():
@jwt_required()
def chat_cancel():
user = get_current_user()
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
conversation_id = data.get('conversation_id')
if not conversation_id:
return jsonify({'error': 'conversation_id is required'}), 400
+4 -3
View File
@@ -1,6 +1,6 @@
"""API Key management endpoints."""
from datetime import datetime
from flask import Blueprint, jsonify, request
from flask import Blueprint, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.middleware.rbac import get_current_user
from app.middleware.api_scope_middleware import SCOPES, require_scope
@@ -8,6 +8,7 @@ from app.services.api_key_service import ApiKeyService
from app.services.audit_service import AuditService
from app.models.audit_log import AuditLog
from app.exceptions import not_found
from app.api._request import json_object
api_keys_bp = Blueprint('api_keys', __name__)
@@ -40,7 +41,7 @@ def create_key():
if not user or not user.is_developer:
return jsonify({'error': 'Developer access required'}), 403
data = request.get_json() or {}
data = json_object()
name = data.get('name')
if not name:
return jsonify({'error': 'Name is required'}), 400
@@ -100,7 +101,7 @@ def update_key(key_id):
if not user or not user.is_developer:
return jsonify({'error': 'Developer access required'}), 403
data = request.get_json() or {}
data = json_object()
api_key = ApiKeyService.update_key(
key_id=key_id,
user_id=user.id,
+9 -8
View File
@@ -1,6 +1,6 @@
# Bucket: PER-APP (plan 29 #9). Reads gate on can_access_app; attach/detach
# Bucket: PER-APP. Reads gate on can_access_app; attach/detach
# gate on can_edit_app for the app AND can_access_app for the service used.
"""Attach services an app uses (plan 86 §C2/§C4). Mounted under ``/api/v1/apps``.
"""Attach services an app uses. Mounted under ``/api/v1/apps``.
GET /<app_id>/attachments — this app's attachments
GET /attachments/services?kind=<kind> — installed services for a kind
@@ -19,6 +19,7 @@ from app.models.app_attachment import AppAttachment
from app.services.app_attachment_service import (
FAILURE_MODES, AppAttachmentService, AttachmentError)
from app.services.resource_grant_service import ResourceGrantService
from app.api._request import json_object
app_attachments_bp = Blueprint('app_attachments', __name__)
@@ -58,7 +59,7 @@ def attach_service(app_id, kind):
app = AppAttachmentService.live_app(app_id)
if app is None or not ResourceGrantService.can_edit_app(user, app):
raise not_found('service')
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
service_app_id = data.get('service_app_id')
if not isinstance(service_app_id, int) or isinstance(service_app_id, bool):
raise ValidationError("'service_app_id' is required")
@@ -90,7 +91,7 @@ def detach(app_id, attachment_id):
return jsonify(body)
# ==================== CONNECTION POOLING (plan 86 §D1) ====================
# ==================== CONNECTION POOLING ====================
@app_attachments_bp.route('/<int:app_id>/pooler', methods=['GET'])
@viewer_required
@@ -114,14 +115,14 @@ def set_pooler(app_id):
raise not_found('service')
if not pooler_service.is_postgres_engine(app):
raise ValidationError('Connection pooling is for an installed PostgreSQL')
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
if not isinstance(data.get('enabled'), bool):
raise ValidationError("'enabled' must be true or false")
result = pooler_service.set_enabled(app, data['enabled'])
return jsonify(result)
# ==================== BOTTLENECK HINTS (plan 86 §A5) ====================
# ==================== BOTTLENECK HINTS ====================
@app_attachments_bp.route('/<int:app_id>/hints', methods=['GET'])
@viewer_required
@@ -133,7 +134,7 @@ def get_hints(app_id):
return jsonify({'hints': BottleneckHintsService.hints(app)})
# ==================== IMMUTABLE ASSETS (plan 86 §B2) ====================
# ==================== IMMUTABLE ASSETS ====================
@app_attachments_bp.route('/<int:app_id>/immutable-assets', methods=['PUT'])
@developer_required
@@ -144,7 +145,7 @@ def set_immutable_assets(app_id):
app = AppAttachmentService.live_app(app_id)
if app is None or not ResourceGrantService.can_edit_app(get_current_user(), app):
raise not_found('service')
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
if not isinstance(data.get('enabled'), bool):
raise ValidationError("'enabled' must be true or false")
return jsonify(SiteDomainService.set_immutable_assets(app, data['enabled']))
+16 -25
View File
@@ -1,6 +1,6 @@
# Bucket: PER-APP (plan 29 #9). Reads gate on can_access_app; changes gate on
# Bucket: PER-APP. Reads gate on can_access_app; changes gate on
# can_edit_app.
"""A/B slot deploys (plan 87). Mounted under ``/api/v1/apps``.
"""A/B slot deploys. Mounted under ``/api/v1/apps``.
GET /<app_id>/slots — slots, active slot, eligibility + reasons
PUT /<app_id>/slots — {enabled}: opt in (adopts the current
@@ -17,38 +17,29 @@
release ran. Destroys later writes; an
explicit second step after a rollback.
"""
from flask import Blueprint, jsonify, request
from flask import Blueprint, jsonify
from app.exceptions import ValidationError, not_found
from app.exceptions import ValidationError
from app.middleware.rbac import developer_required, get_current_user, viewer_required
from app.services.resource_grant_service import ResourceGrantService
from app.services.slot_deploy_service import SlotDeployService
from app.api._access import require_app
from app.api._request import json_object
app_slots_bp = Blueprint('app_slots', __name__)
def _app(app_id, write=False):
app = SlotDeployService.live_app(app_id)
user = get_current_user()
allowed = (ResourceGrantService.can_edit_app if write
else ResourceGrantService.can_access_app)
if app is None or not allowed(user, app):
raise not_found('service')
return app, user
@app_slots_bp.route('/<int:app_id>/slots', methods=['GET'])
@viewer_required
def get_slots(app_id):
app, _ = _app(app_id)
app = require_app(app_id, conceal=True)
return jsonify(SlotDeployService.status(app))
@app_slots_bp.route('/<int:app_id>/slots', methods=['PUT'])
@developer_required
def set_slots(app_id):
app, _ = _app(app_id, write=True)
data = request.get_json(silent=True) or {}
app = require_app(app_id, write=True, conceal=True)
data = json_object(optional=True)
enabled = data.get('enabled')
if not isinstance(enabled, bool):
raise ValidationError("'enabled' must be true or false")
@@ -61,8 +52,8 @@ def set_slots(app_id):
@app_slots_bp.route('/<int:app_id>/slots/switch-back', methods=['POST'])
@developer_required
def switch_back(app_id):
app, user = _app(app_id, write=True)
result = SlotDeployService.switch_back(app, user_id=user.id)
app = require_app(app_id, write=True, conceal=True)
result = SlotDeployService.switch_back(app, user_id=get_current_user().id)
if not result.get('success'):
return jsonify(result), 409
return jsonify(result)
@@ -71,8 +62,8 @@ def switch_back(app_id):
@app_slots_bp.route('/<int:app_id>/slots/restore-db', methods=['POST'])
@developer_required
def restore_db(app_id):
app, _ = _app(app_id, write=True)
data = request.get_json(silent=True) or {}
app = require_app(app_id, write=True, conceal=True)
data = json_object(optional=True)
deployment_id = data.get('deployment_id')
if not isinstance(deployment_id, int) or isinstance(deployment_id, bool):
raise ValidationError("'deployment_id' is required")
@@ -85,7 +76,7 @@ def restore_db(app_id):
@app_slots_bp.route('/<int:app_id>/slots/compose-split', methods=['GET'])
@viewer_required
def compose_split_preview(app_id):
app, _ = _app(app_id)
app = require_app(app_id, conceal=True)
result = SlotDeployService.compose_split_preview(app)
if not result.get('success'):
return jsonify(result), 409
@@ -95,8 +86,8 @@ def compose_split_preview(app_id):
@app_slots_bp.route('/<int:app_id>/slots/compose-split', methods=['POST'])
@developer_required
def compose_split_apply(app_id):
app, _ = _app(app_id, write=True)
data = request.get_json(silent=True) or {}
app = require_app(app_id, write=True, conceal=True)
data = json_object(optional=True)
if data.get('confirm') is not True:
raise ValidationError("Moving the stateful services stops the service once; send "
"{'confirm': true} after reading the preview")
+9 -32
View File
@@ -1,4 +1,4 @@
# Bucket: PER-APP (plan 29 #9). Volume routes gate on the shared app-access seam
# Bucket: PER-APP. Volume routes gate on the shared app-access seam
# (can_access_app for reads, can_edit_app for attach/detach mutations).
"""Per-app managed volume API — attach/list/detach first-class persistent
volumes for an application. Mounted under ``/api/v1/apps`` alongside the main
@@ -7,34 +7,17 @@ apps blueprint (routes are ``/<app_id>/volumes*``).
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required
from app.models import Application
from app.services.resource_grant_service import ResourceGrantService
from app.services.volume_service import VolumeService, VolumeError
from app.middleware.rbac import get_current_user
from app.exceptions import not_found, permission_denied
from app.api._access import require_app
from app.api._request import json_object
app_volumes_bp = Blueprint('app_volumes', __name__)
def _load_app_for(app_id, *, write):
"""Resolve (user, app) with the right ACL, or an (error_response, status)."""
user = get_current_user()
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
allowed = (ResourceGrantService.can_edit_app(user, app) if write
else ResourceGrantService.can_access_app(user, app))
if not allowed:
raise permission_denied()
return user, app, None
@app_volumes_bp.route('/<int:app_id>/volumes', methods=['GET'])
@jwt_required()
def list_volumes(app_id):
_user, app, err = _load_app_for(app_id, write=False)
if err:
return err
app = require_app(app_id)
volumes = [v.to_dict(live=live) for v, live in VolumeService.list_for_app(app)]
return jsonify({'volumes': volumes})
@@ -42,10 +25,8 @@ def list_volumes(app_id):
@app_volumes_bp.route('/<int:app_id>/volumes', methods=['POST'])
@jwt_required()
def attach_volume(app_id):
_user, app, err = _load_app_for(app_id, write=True)
if err:
return err
data = request.get_json() or {}
app = require_app(app_id, write=True)
data = json_object()
try:
volume = VolumeService.create(
app,
@@ -63,9 +44,7 @@ def attach_volume(app_id):
@app_volumes_bp.route('/<int:app_id>/volumes/<int:volume_id>', methods=['DELETE'])
@jwt_required()
def detach_volume(app_id, volume_id):
_user, app, err = _load_app_for(app_id, write=True)
if err:
return err
app = require_app(app_id, write=True)
volume = VolumeService.get(volume_id)
if not volume or volume.application_id != app.id:
return jsonify({'error': 'Volume not found'}), 404
@@ -83,10 +62,8 @@ def convert_bind_mount(app_id):
"""Create a managed volume and copy an existing host directory's contents
into it. (Deviates from the plan's ``/<vid>/convert`` — a convert creates a
new volume, so there is no prior vid.)"""
_user, app, err = _load_app_for(app_id, write=True)
if err:
return err
data = request.get_json() or {}
app = require_app(app_id, write=True)
data = json_object()
host_path = data.get('host_path')
mount_path = data.get('mount_path')
if not host_path or not mount_path:
+62 -70
View File
@@ -1,4 +1,4 @@
# Bucket: PER-APP (plan 29 #9). Per-app routes gate on the shared app-access
# Bucket: PER-APP. Per-app routes gate on the shared app-access
# seam via the file-local _can_access_app (reads) / _can_edit_app (writes)
# helpers, or the hand-rolled owner-or-admin idiom; host/system ops stay admin.
import os
@@ -45,7 +45,11 @@ from app.services.upload_service import (
from app import paths
from app.middleware.rbac import get_current_user, require_admin_user
from app.error_reporting import unexpected_response
from app.exceptions import field_required, not_found, permission_denied
from app.exceptions import (
PermissionDeniedError, ValidationError, field_required, not_found, permission_denied,
)
from app.api._access import require_app
from app.api._request import json_object
apps_bp = Blueprint('apps', __name__)
@@ -141,7 +145,7 @@ def link_apps(app_id):
if not _can_edit_app(user, app):
raise permission_denied()
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -328,7 +332,7 @@ def update_environment(app_id):
if not _can_edit_app(user, app):
raise permission_denied()
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -437,7 +441,7 @@ def set_app_workspace(app_id):
if not user.is_admin and app.user_id != user.id:
raise permission_denied()
target = (request.get_json() or {}).get('workspace_id')
target = json_object().get('workspace_id')
if target in (None, '', 'default'):
ws_id = WorkspaceService.ensure_default_workspace().id
else:
@@ -478,7 +482,7 @@ def move_apps_to_project():
from app.models.environment import Environment
user = get_current_user()
data = request.get_json() or {}
data = json_object()
raw_ids = data.get('app_ids')
if not isinstance(raw_ids, list) or not raw_ids:
@@ -674,7 +678,7 @@ def grant_app_access(app_id):
raise not_found('service')
if not user.is_admin and app.user_id != user.id:
raise permission_denied()
data = request.get_json() or {}
data = json_object()
grantee_id = data.get('user_id')
if not grantee_id:
raise field_required('user_id')
@@ -712,7 +716,7 @@ def create_app_from_repository():
"""Create a new application by cloning a Git repository."""
user = require_admin_user()
data = request.get_json() or {}
data = json_object()
name = _service_slug(data.get('name'))
repo_url = (data.get('repo_url') or '').strip()
source_connection_id = data.get('source_connection_id')
@@ -910,16 +914,27 @@ def create_app_from_repository():
return unexpected_response(exc)
# Fields that point the row at host paths and units root-run code acts on
# (docker compose up, nginx site files, systemctl) — admin-only, as /manual.
_HOST_BINDING_FIELDS = ('root_path', 'compose_file', 'systemd_unit', 'managed_by')
@apps_bp.route('', methods=['POST'])
@jwt_required()
def create_app():
current_user_id = get_jwt_identity()
data = request.get_json()
user = get_current_user()
current_user_id = user.id
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
name = data.get('name')
host_fields = [f for f in _HOST_BINDING_FIELDS if data.get(f)]
if host_fields and not user.is_admin:
raise PermissionDeniedError(f"Only an admin can set {', '.join(host_fields)}")
# The name becomes the nginx vhost name and its file path.
name = _service_slug(data.get('name'))
app_type = data.get('app_type')
if not all([name, app_type]):
@@ -929,9 +944,12 @@ def create_app():
if app_type not in valid_types:
return jsonify({'error': f'Invalid app_type. Must be one of: {", ".join(valid_types)}'}), 400
clash = Application.name_clash_error(name)
if clash:
raise ValidationError(clash)
# Stamp the workspace (#33): the requested one (membership-checked) or the default.
from app.services.workspace_service import WorkspaceService
user = User.query.get(current_user_id)
ws_id = WorkspaceService.resolve_workspace_id(
user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id'))
# Role reconciliation (#33): a workspace 'viewer' member has read-only access to
@@ -980,7 +998,7 @@ def create_manual_app():
"""Register an app that already exists on the server (manual/local)."""
user = require_admin_user()
data = request.get_json() or {}
data = json_object()
name = _service_slug(data.get('name'))
app_type = (data.get('app_type') or 'docker').strip().lower()
root_path = (data.get('root_path') or '').strip()
@@ -1257,7 +1275,7 @@ def rollback_app_version(app_id):
if app.source != 'upload':
return jsonify({'error': 'Rollback is only available for services deployed by upload'}), 400
data = request.get_json() or {}
data = json_object()
target = data.get('version')
if target is None:
raise field_required('version')
@@ -1307,12 +1325,20 @@ def update_app(app_id):
if not _can_edit_app(user, app):
raise permission_denied()
data = request.get_json()
data = json_object()
# Validate before anything is assigned, so a bad setting leaves the app
# untouched rather than half-updated in the session.
host_fields = [f for f in _HOST_BINDING_FIELDS if f in data]
if host_fields and not user.is_admin:
raise PermissionDeniedError(f"Only an admin can change {', '.join(host_fields)}")
if 'name' in data and data['name'] != app.name:
if not data['name'] or _service_slug(data['name']) != data['name']:
raise ValidationError('Service name must be lowercase letters, digits and hyphens')
clash = Application.name_clash_error(data['name'])
if clash:
raise ValidationError(clash)
if 'deploy_settings' in data:
from app.exceptions import ValidationError
from app.services import deploy_settings
_, error = deploy_settings.validate(data['deploy_settings'])
if error:
@@ -1526,7 +1552,7 @@ def update_sleep_policy(app_id):
raise not_found('service')
if not _can_edit_app(user, app):
raise permission_denied()
data = request.get_json() or {}
data = json_object()
policy = ContainerSleepService.set_policy(
app_id, enabled=data.get('enabled'), idle_timeout_minutes=data.get('idle_timeout_minutes'))
return jsonify(policy.to_dict())
@@ -1648,7 +1674,7 @@ def update_app_resources(app_id):
if not _can_edit_app(user, app):
raise permission_denied()
data = request.get_json() or {}
data = json_object()
try:
if 'cpu_limit' in data:
app.cpu_limit = DockerService.validate_cpu_limit(data['cpu_limit'])
@@ -1705,7 +1731,7 @@ def set_micro_cache(app_id):
if not _can_edit_app(user, app):
raise permission_denied()
data = request.get_json() or {}
data = json_object()
if 'enabled' not in data:
return jsonify({'error': "'enabled' is required"}), 400
@@ -1751,7 +1777,7 @@ def set_micro_cache(app_id):
@apps_bp.route('/<int:app_id>/micro-cache/purge', methods=['POST'])
@jwt_required()
def purge_micro_cache(app_id):
"""Clear this site's cached pages (plan 86 §B3).
"""Clear this site's cached pages.
The zone is shared, but the cache key carries the host, so only entries
for this app's domains are removed. No nginx reload is needed.
@@ -2176,25 +2202,11 @@ def get_app_status(app_id):
# ==================== BACKUP PROTECTION (policy + runs) ====================
def _load_app_for_backup(app_id, edit=False):
"""Load an app the current user may access (or edit). Returns (app, error)."""
user = get_current_user()
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
allowed = _can_edit_app(user, app) if edit else _can_access_app(user, app)
if not allowed:
raise permission_denied()
return app, None
@apps_bp.route('/<int:app_id>/backup-policy', methods=['GET'])
@jwt_required()
def get_app_backup_policy(app_id):
"""Return the protection policy + status for an app (creating a default)."""
app, err = _load_app_for_backup(app_id)
if err:
return err
app = require_app(app_id)
policy = BackupPolicyService.get_or_create_policy('application', app_id)
return jsonify(BackupPolicyService.serialize_policy_view(policy))
@@ -2203,12 +2215,10 @@ def get_app_backup_policy(app_id):
@jwt_required()
def update_app_backup_policy(app_id):
"""Update the protection policy and re-sync its schedule."""
app, err = _load_app_for_backup(app_id, edit=True)
if err:
return err
app = require_app(app_id, write=True)
policy = BackupPolicyService.get_or_create_policy('application', app_id)
try:
BackupPolicyService.update_policy(policy, request.get_json() or {})
BackupPolicyService.update_policy(policy, json_object())
except BackupPolicyError as e:
return jsonify({'error': str(e)}), 400
return jsonify(BackupPolicyService.serialize_policy_view(policy))
@@ -2218,9 +2228,7 @@ def update_app_backup_policy(app_id):
@jwt_required()
def trigger_app_backup(app_id):
"""Enqueue a one-off backup for the app."""
app, err = _load_app_for_backup(app_id, edit=True)
if err:
return err
app = require_app(app_id, write=True)
policy = BackupPolicyService.get_or_create_policy('application', app_id)
try:
job = BackupPolicyService.run_policy_now(policy, manual=True)
@@ -2233,9 +2241,7 @@ def trigger_app_backup(app_id):
@jwt_required()
def list_app_backups(app_id):
"""List backup runs for the app."""
app, err = _load_app_for_backup(app_id)
if err:
return err
app = require_app(app_id)
policy = BackupPolicyService.get_or_create_policy('application', app_id)
return jsonify({'runs': BackupPolicyService.list_runs(policy)})
@@ -2244,12 +2250,10 @@ def list_app_backups(app_id):
@jwt_required()
def restore_app_backup(app_id, run_id):
"""Enqueue a restore from a specific backup run."""
app, err = _load_app_for_backup(app_id, edit=True)
if err:
return err
app = require_app(app_id, write=True)
policy = BackupPolicyService.get_or_create_policy('application', app_id)
try:
job = BackupPolicyService.request_restore(policy, run_id, request.get_json() or {})
job = BackupPolicyService.request_restore(policy, run_id, json_object())
except BackupPolicyError as e:
return jsonify({'error': str(e)}), 400
return jsonify({'success': True, 'job_id': job.id}), 202
@@ -2259,9 +2263,7 @@ def restore_app_backup(app_id, run_id):
@jwt_required()
def verify_app_backup(app_id, run_id):
"""Verify the remote copy of a backup run."""
app, err = _load_app_for_backup(app_id, edit=True)
if err:
return err
app = require_app(app_id, write=True)
policy = BackupPolicyService.get_or_create_policy('application', app_id)
try:
result = BackupPolicyService.verify_run(policy, run_id)
@@ -2274,9 +2276,7 @@ def verify_app_backup(app_id, run_id):
@jwt_required()
def delete_app_backup(app_id, run_id):
"""Delete a backup run (local + remote + record)."""
app, err = _load_app_for_backup(app_id, edit=True)
if err:
return err
app = require_app(app_id, write=True)
policy = BackupPolicyService.get_or_create_policy('application', app_id)
try:
BackupPolicyService.delete_run(policy, run_id)
@@ -2286,7 +2286,7 @@ def delete_app_backup(app_id, run_id):
# --------------------------------------------------------------------------- #
# WordPress reachable through the generic application surface (§7 unification).
# WordPress reachable through the generic application surface.
# A WordPress site is a 1:1 extension of an Application (WordPressSite.
# application_id); the WordPressSite record — not app_type — is the source of
# truth (see wordpress.py:_is_wp_app). These additive routes expose the WP
@@ -2304,9 +2304,7 @@ def _wp_site_for_app(app):
def app_wordpress_info(app_id):
"""Whether this app is a WordPress site, and the linked site id. Lets the
generic Services surface discover the WordPress extension."""
app, err = _load_app_for_backup(app_id)
if err:
return err
app = require_app(app_id)
site = _wp_site_for_app(app)
if not site:
return jsonify({'is_wordpress': False, 'site_id': None}), 200
@@ -2323,9 +2321,7 @@ def app_wordpress_info(app_id):
@jwt_required()
def list_app_db_snapshots(app_id):
"""List the WordPress DB snapshots for an app (unified app-id surface)."""
app, err = _load_app_for_backup(app_id)
if err:
return err
app = require_app(app_id)
from app.models.wordpress_site import DatabaseSnapshot
site = _wp_site_for_app(app)
if not site:
@@ -2339,9 +2335,7 @@ def list_app_db_snapshots(app_id):
@jwt_required()
def create_app_db_snapshot(app_id):
"""Create a WordPress DB snapshot for an app."""
app, err = _load_app_for_backup(app_id, edit=True)
if err:
return err
app = require_app(app_id, write=True)
from app.models.wordpress_site import DatabaseSnapshot
from app.services.db_sync_service import DatabaseSyncService
site = _wp_site_for_app(app)
@@ -2349,7 +2343,7 @@ def create_app_db_snapshot(app_id):
return jsonify({'error': 'This service is not a WordPress site'}), 400
# Resolve the extension only once we know this IS a WordPress site — with
# serverkit-wordpress uninstalled a non-WP app must get the 400, not an
# import error (plan 52 Phase 5 graceful absence). A WP SITE with the
# import error (graceful absence). A WP SITE with the
# extension missing/disabled gets a clean 503, not an uncaught
# WordPressExtensionMissingError → 500 (audit F3).
from app.services.wordpress_bridge import (
@@ -2359,7 +2353,7 @@ def create_app_db_snapshot(app_id):
WordPressEnvService = wordpress_env_service()
except WordPressExtensionMissingError as e:
return jsonify({'error': str(e)}), 503
data = request.get_json() or {}
data = json_object()
result = DatabaseSyncService.create_snapshot(
db_name=site.db_name,
name=data.get('name', f'{app.name}_{site.id}'),
@@ -2389,9 +2383,7 @@ def create_app_db_snapshot(app_id):
@jwt_required()
def delete_app_db_snapshot(app_id, snapshot_id):
"""Delete a WordPress DB snapshot for an app."""
app, err = _load_app_for_backup(app_id, edit=True)
if err:
return err
app = require_app(app_id, write=True)
from app.models.wordpress_site import DatabaseSnapshot
from app.services.db_sync_service import DatabaseSyncService
site = _wp_site_for_app(app)
+13 -12
View File
@@ -22,6 +22,7 @@ from app.utils.i18n import normalize_language
from app.exceptions import (
AuthenticationError, ConflictError, PermissionDeniedError, ValidationError, not_found,
)
from app.api._request import json_object
logger = logging.getLogger(__name__)
@@ -75,7 +76,7 @@ def get_setup_status():
@auth_bp.route('/register', methods=['POST'])
@limiter.limit("3 per minute")
def register():
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -195,7 +196,7 @@ def complete_onboarding():
"""Complete the onboarding wizard and mark setup as done."""
user = require_admin_user()
data = request.get_json() or {}
data = json_object()
use_cases = data.get('use_cases', [])
# Validate use_cases
@@ -208,7 +209,7 @@ def complete_onboarding():
# 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).
# just persists what onboarding did.
installed_extensions = data.get('installed_extensions', [])
if not isinstance(installed_extensions, list):
return jsonify({'error': 'installed_extensions must be a list'}), 400
@@ -216,7 +217,7 @@ def complete_onboarding():
s for s in installed_extensions if isinstance(s, str) and s.strip()
]
# Security posture (plan 47 Ph5) — persisted separately from use_cases so
# Security posture — persisted separately from use_cases so
# posture and purpose stay orthogonal. Absent/invalid falls back to the
# lean default rather than failing the whole onboarding.
security_posture = data.get('security_posture', 'minimal')
@@ -232,7 +233,7 @@ def complete_onboarding():
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).
# user didn't install so the boot seeder won't re-add them.
try:
from app.services import plugin_service
plugin_service.finalize_setup_flagships()
@@ -262,7 +263,7 @@ def login():
'error': 'Too many failed login attempts. Try again later.'
}), 429, {'Retry-After': str(retry_after)}
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -346,7 +347,7 @@ def login():
def create_login_link():
"""Mint a single-use login URL. The raw token is returned exactly once."""
current = require_admin_user()
data = request.get_json() or {}
data = json_object()
target_id = data.get('user_id') or current.id
target = User.query.get(target_id)
@@ -422,7 +423,7 @@ def redeem_login_link():
'error': 'Too many failed attempts. Try again later.'
}), 429, {'Retry-After': str(retry_after)}
data = request.get_json() or {}
data = json_object()
token = data.get('token')
user, reason = login_link_service.redeem(token, client_ip)
@@ -599,7 +600,7 @@ def update_current_user():
if not user:
raise not_found('user')
data = request.get_json() or {}
data = json_object()
if 'password' in data:
import time
@@ -693,7 +694,7 @@ def passkey_register():
user = User.query.get(user_id)
if not user:
raise not_found('user')
data = request.get_json() or {}
data = json_object()
credential = data.get('credential')
device_name = data.get('device_name', 'Passkey')
if not credential:
@@ -725,7 +726,7 @@ def passkey_delete(passkey_id):
def passkey_auth_options():
"""Begin WebAuthn authentication (public, challenge only)."""
from app.services.passkey_service import PasskeyService
data = request.get_json() or {}
data = json_object()
user_id = data.get('user_id')
user = User.query.get(user_id) if user_id else None
options = PasskeyService.begin_authentication(user)
@@ -738,7 +739,7 @@ def passkey_authenticate():
"""Verify a WebAuthn assertion and issue JWT tokens."""
from app.services.passkey_service import PasskeyService
from app.services import sso_service
data = request.get_json() or {}
data = json_object()
credential = data.get('credential')
user_id = data.get('user_id')
if not credential:
+21 -20
View File
@@ -8,11 +8,12 @@ from app.services.backup_policy_service import BackupPolicyService, BackupPolicy
from app.services.storage_provider_service import StorageProviderService
from app import db, paths
from app.exceptions import field_required, not_found
from app.api._request import json_object
backups_bp = Blueprint('backups', __name__)
# UI-facing metadata for the CORE target types (plan 52 D4). Extension kinds
# UI-facing metadata for the CORE target types. Extension kinds
# carry their own label/scopes in backup_kind_registry. 'server' is listed
# but flagged unimplemented so the UI never shows a dead whole-server button.
_CORE_TARGET_META = {
@@ -77,7 +78,7 @@ def get_stats():
@admin_required
def list_policies():
"""Global list of policy-driven backup schedules across every target type
(§8 unification). Optional filters: ?target_type=, ?enabled=true|false.
. Optional filters: ?target_type=, ?enabled=true|false.
Replaces the legacy filesystem schedule list (/backups/schedules) for the
unified Backups page — one queryable view of every BackupPolicy.
@@ -113,7 +114,7 @@ def list_policies():
@backups_bp.route('/runs', methods=['GET'])
@admin_required
def list_runs():
"""Global, queryable list of backup runs across every policy (§8).
"""Global, queryable list of backup runs across every policy.
Optional filters: ?target_type=, ?status=, ?policy_id=, ?limit= (<=500).
Each run is augmented with its policy's target_type/target_id so the unified
@@ -149,7 +150,7 @@ def list_runs():
# --------------------------------------------------------------------------- #
# Generic policy-driven backup CRUD by (target_type, target_id) (§8).
# Generic policy-driven backup CRUD by (target_type, target_id).
# One surface for every BackupPolicy target — apps, WordPress sites, databases,
# file path-lists, servers. The per-resource mounts
# (/apps/:id/backup-policy, /wordpress/sites/:id/backup-policy) remain as
@@ -174,7 +175,7 @@ def get_target_policy(target_type, target_id):
def update_target_policy(target_type, target_id):
"""Update a policy. Body may include target_subtype/target_meta (needed to
describe database/files targets) plus the usual schedule/retention fields."""
data = request.get_json() or {}
data = json_object()
subtype = data.pop('target_subtype', None)
meta = data.pop('target_meta', None)
try:
@@ -216,7 +217,7 @@ def restore_target_run(target_type, target_id, run_id):
try:
BackupPolicyService.validate_target_type(target_type)
policy = BackupPolicyService.get_or_create_policy(target_type, target_id)
job = BackupPolicyService.request_restore(policy, run_id, request.get_json() or {})
job = BackupPolicyService.request_restore(policy, run_id, json_object())
except BackupPolicyError as e:
return jsonify({'error': str(e)}), 400
return jsonify({'success': True, 'job_id': job.id}), 202
@@ -258,7 +259,7 @@ def get_config():
@admin_required
def update_config():
"""Update backup configuration."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -282,7 +283,7 @@ def get_cost_rates():
def update_cost_rates():
"""Update storage-cost rates. Accepts {rates:{local,s3,b2}} or the flat dict."""
from app.services.backup_cost_service import BackupCostService
data = request.get_json() or {}
data = json_object()
rates = BackupCostService.save_rates(data.get('rates') if 'rates' in data else data)
return jsonify({'success': True, 'rates': rates}), 200
@@ -334,7 +335,7 @@ def cost_summary():
@admin_required
def backup_application():
"""Backup an application."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -361,7 +362,7 @@ def backup_application():
@admin_required
def backup_database():
"""Backup a database."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -386,7 +387,7 @@ def backup_database():
@admin_required
def backup_files():
"""Backup specific files and directories."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -407,7 +408,7 @@ def backup_files():
@admin_required
def restore_application():
"""Restore an application from backup."""
data = request.get_json()
data = json_object()
if not data or 'backup_path' not in data:
raise field_required('backup_path')
@@ -424,7 +425,7 @@ def restore_application():
@admin_required
def restore_database():
"""Restore a database from backup."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -462,7 +463,7 @@ def delete_backup(backup_path):
@admin_required
def cleanup_backups():
"""Clean up old backups."""
data = request.get_json() or {}
data = json_object()
retention_days = data.get('retention_days')
result = BackupService.cleanup_old_backups(retention_days)
@@ -484,7 +485,7 @@ def list_schedules():
@admin_required
def add_schedule():
"""Add a backup schedule."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -510,7 +511,7 @@ def add_schedule():
@admin_required
def update_schedule(schedule_id):
"""Update a backup schedule."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -547,7 +548,7 @@ def get_storage_config():
@admin_required
def update_storage_config():
"""Update storage provider configuration."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -575,7 +576,7 @@ def test_storage_connection():
@admin_required
def upload_to_remote():
"""Upload a local backup to remote storage."""
data = request.get_json()
data = json_object()
if not data or 'backup_path' not in data:
raise field_required('backup_path')
@@ -598,7 +599,7 @@ def upload_to_remote():
@admin_required
def verify_remote_backup():
"""Verify a backup exists and matches on remote storage."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -629,7 +630,7 @@ def list_remote_backups():
@admin_required
def download_from_remote():
"""Download a backup from remote storage to local."""
data = request.get_json()
data = json_object()
if not data or 'remote_key' not in data:
raise field_required('remote_key')
+17 -22
View File
@@ -1,4 +1,4 @@
# Bucket: PER-APP read (plan 29 #9). Per-app bandwidth is scoped to callers who
# Bucket: PER-APP read. Per-app bandwidth is scoped to callers who
# can access the app (can_access_app); the cross-app overview stays panel-wide.
"""REST surface for per-domain bandwidth accounting.
@@ -14,6 +14,7 @@ from ..models import Application
from ..services.bandwidth_service import BandwidthService
from ..services.request_metrics_service import RequestMetricsService
from ..services.resource_grant_service import ResourceGrantService
from app.api._request import json_object
bandwidth_bp = Blueprint('bandwidth', __name__)
@@ -23,42 +24,36 @@ bandwidth_bp = Blueprint('bandwidth', __name__)
def get_apps_bandwidth():
"""Month totals + 30-day sparkline series for every app with traffic —
one call for the Services list."""
try:
data = BandwidthService.overview(days=30)
# JSON object keys must be strings.
return jsonify({'apps': {str(k): v for k, v in data.items()}})
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)
data = BandwidthService.overview(days=30)
# JSON object keys must be strings.
return jsonify({'apps': {str(k): v for k, v in data.items()}})
@bandwidth_bp.route('/apps/<int:app_id>', methods=['GET'])
@viewer_required
def get_app_bandwidth(app_id):
"""Full daily series (default 90 days) + current-month total for one app,
scoped to the app's workspace visibility (plan 29 #9 — foreign caller 404)."""
scoped to the app's workspace visibility (foreign caller 404)."""
app = Application.query_active().filter_by(id=app_id).first()
user = get_current_user()
if app is None or not ResourceGrantService.can_access_app(user, app):
return jsonify({'error': 'Not found'}), 404
try:
days = request.args.get('days', 90, type=int)
series = BandwidthService.series(app_id=app_id, days=days)
return jsonify({
'app_id': app_id,
'days': len(series),
'series': series,
'month_bytes': BandwidthService.monthly_total(app_id),
})
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)
days = request.args.get('days', 90, type=int)
series = BandwidthService.series(app_id=app_id, days=days)
return jsonify({
'app_id': app_id,
'days': len(series),
'series': series,
'month_bytes': BandwidthService.monthly_total(app_id),
})
@bandwidth_bp.route('/apps/<int:app_id>/requests', methods=['GET'])
@viewer_required
def get_app_request_metrics(app_id):
"""Request rate, status classes, latency percentiles and cache hit ratio
from the timed access log (plan 86 §A2). ``period``: 1h, 24h, 7d or 30d."""
# Same 404 for a missing and a foreign app (plan 29 #9); a bad period is a
from the timed access log. ``period``: 1h, 24h, 7d or 30d."""
# Same 404 for a missing and a foreign app; a bad period is a
# ValidationError (400). Both go through the global handler.
app = RequestMetricsService.live_app(app_id)
if app is None or not ResourceGrantService.can_access_app(get_current_user(), app):
@@ -72,7 +67,7 @@ def get_app_request_metrics(app_id):
def run_aggregate():
"""Run the daily aggregation now (optionally for a specific day)."""
try:
payload = request.get_json(silent=True) or {}
payload = json_object(optional=True)
result = BandwidthService.aggregate(day=payload.get('day'))
return jsonify(result)
except ValueError:
+4 -3
View File
@@ -16,13 +16,14 @@ import os
import shutil
import tempfile
from flask import Blueprint, request, jsonify
from flask import Blueprint, jsonify
from flask_jwt_extended import jwt_required
from app.services.buildpack_service import BuildpackService
from app.services.git_service import GitService
from app.services.source_connection_service import SourceConnectionService
from app.middleware.rbac import developer_required, get_current_user
from app.api._request import json_object
buildpacks_bp = Blueprint('buildpacks', __name__)
@@ -46,7 +47,7 @@ def detect():
If ``path`` is supplied it is used directly (no clone). Otherwise the repo is
cloned shallowly to a temp workspace, inspected, and the workspace removed.
"""
data = request.get_json() or {}
data = json_object()
# Through the one identity door: @developer_required admits API-key
# callers, for whom reading the JWT directly raises.
caller = get_current_user()
@@ -125,7 +126,7 @@ def generate():
Body: {plan, overrides?, name?}
"""
data = request.get_json() or {}
data = json_object()
plan = data.get('plan')
if not isinstance(plan, dict):
return jsonify({'error': 'plan (object) is required'}), 400
+7 -6
View File
@@ -1,4 +1,4 @@
# Bucket: PER-APP (plan 29 #9). Per-app build routes gate via the app-grant
# Bucket: PER-APP. Per-app build routes gate via the app-grant
# family (can_access_app / can_edit_app). See docs/WORKSPACE_SCOPING.md.
"""
Build and Deployment API Endpoints
@@ -18,6 +18,7 @@ from app.services.build_service import BuildService
from app.services.deployment_service import DeploymentService
from app.services.resource_grant_service import ResourceGrantService
from app.exceptions import not_found, permission_denied
from app.api._request import json_object
builds_bp = Blueprint('builds', __name__)
@@ -53,7 +54,7 @@ def configure_build(app_id):
if not app:
raise not_found('service')
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -140,7 +141,7 @@ def trigger_build(app_id):
if not ResourceGrantService.can_edit_app(user, app):
raise permission_denied()
data = request.get_json() or {}
data = json_object()
no_cache = data.get('no_cache', False)
result = BuildService.build(app_id, no_cache=no_cache)
@@ -201,7 +202,7 @@ def clear_build_cache(app_id):
def deploy_app(app_id):
"""Deploy an application (build + deploy).
Every deploy is a job (plan 51 D7): by default this enqueues an `app_deploy`
Every deploy is a job: by default this enqueues an `app_deploy`
DeploymentJob and returns 202 + {deploy_job_id} so the UI can open the live
Deploy Console. `?wait=true` preserves the old synchronous contract for
CLI/tests (runs inline, returns the deployment result body).
@@ -216,7 +217,7 @@ def deploy_app(app_id):
if not ResourceGrantService.can_edit_app(user, app):
raise permission_denied()
data = request.get_json() or {}
data = json_object()
no_cache = data.get('no_cache', False)
version_tag = data.get('version_tag')
wait = request.args.get('wait', 'false').lower() == 'true'
@@ -331,7 +332,7 @@ def rollback(app_id):
if not ResourceGrantService.can_edit_app(user, app):
raise permission_denied()
data = request.get_json() or {}
data = json_object()
target_version = data.get('version') # Optional - if not provided, rolls back to previous
result = DeploymentService.rollback(
+1 -1
View File
@@ -22,7 +22,7 @@ def get_status():
@connect_bp.route('/managed-profile', methods=['GET'])
@admin_required
def get_managed_profile():
"""The managed profile in force (plan 25). Read-only: the signed
"""The managed profile in force. Read-only: the signed
ui.managed_profile command handler is the only writer, and lifting the
profile from ServerKit Cloud — never from here — is the only off switch
other than expiry or revocation."""
+5 -4
View File
@@ -20,6 +20,7 @@ from app.services.connection_provider_sdk import (
from app.services import connection_providers # noqa: F401
from app.services.container_registry_service import ContainerRegistryService
from app.services.workspace_service import WorkspaceService
from app.api._request import json_object
connections_bp = Blueprint('connections', __name__)
@@ -109,7 +110,7 @@ def list_provider_schemas():
@admin_required
def validate_provider_credentials(kind):
result = ConnectionProviderRegistry.execute(
kind, 'validate', payload=request.get_json() or {},
kind, 'validate', payload=json_object(),
partial=request.args.get('partial', '').lower() == 'true')
return _provider_response(result)
@@ -144,7 +145,7 @@ def provider_test(kind, connection_id):
def provider_rotate(kind, connection_id):
result = ConnectionProviderRegistry.execute(
kind, 'rotate', ref=_connection_ref(kind, connection_id),
payload=request.get_json() or {})
payload=json_object())
_audit_provider_operation(kind, connection_id, 'rotate', result)
return _provider_response(result)
@@ -168,7 +169,7 @@ def list_registries():
@connections_bp.route('/registries', methods=['POST'])
@admin_required
def create_registry():
data = request.get_json() or {}
data = json_object()
name = (data.get('name') or '').strip()
if not name:
return jsonify({'error': 'name is required'}), 400
@@ -191,7 +192,7 @@ def update_registry(registry_id):
registry = ContainerRegistryService.get(registry_id)
if not registry:
return jsonify({'error': 'Registry not found'}), 404
data = request.get_json() or {}
data = json_object()
registry = ContainerRegistryService.update(
registry,
name=data.get('name'),
+1 -1
View File
@@ -1,4 +1,4 @@
# Bucket: PER-APP read (plan 29 #9). The single-app status read gates on app
# Bucket: PER-APP read. The single-app status read gates on app
# visibility via require_app_member (viewer+); the all-apps roll-up is a
# panel-wide authed read.
"""REST surface for the centralized container status aggregator.
+9 -8
View File
@@ -1,4 +1,4 @@
# Bucket: PER-APP (plan 29 #9). Per-app cron routes gate via the app-grant
# Bucket: PER-APP. Per-app cron routes gate via the app-grant
# family (can_access_app / can_edit_app). See docs/WORKSPACE_SCOPING.md.
"""
Cron Job Management API
@@ -10,6 +10,7 @@ from flask import Blueprint, request, jsonify
from app.middleware.rbac import admin_required, viewer_required, require_app_member
from app.services.cron_service import CronService
from app.api._request import json_object
cron_bp = Blueprint('cron', __name__)
@@ -97,7 +98,7 @@ def list_jobs():
@admin_required
def create_job():
"""Create a new cron job."""
data = request.get_json()
data = json_object()
schedule = data.get('schedule')
command = data.get('command')
@@ -131,7 +132,7 @@ def create_job():
@admin_required
def update_job(job_id):
"""Update a cron job."""
data = request.get_json()
data = json_object()
set_application = 'application_id' in data
application_id = None
@@ -170,7 +171,7 @@ def delete_job(job_id):
@admin_required
def toggle_job(job_id):
"""Enable or disable a cron job."""
data = request.get_json()
data = json_object()
enabled = data.get('enabled', True)
result = CronService.toggle_job(job_id, enabled)
@@ -229,7 +230,7 @@ def jobs_for_app(app_id):
def toggle_tracking(job_id):
"""Enable/disable run tracking for a job (rewrites its crontab line with the
serverkit-cron-run shim; disabling restores the bare command)."""
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
enabled = bool(data.get('enabled', True))
options = {}
if 'alert_on_failure' in data:
@@ -263,14 +264,14 @@ def ingest_run():
"""
# The cron-run shim posts DIRECTLY to the panel's loopback gunicorn port
# (cli_api_client base http://127.0.0.1:<port>), never through nginx, so no
# X-Forwarded-For is present and ProxyFix (plan 48) leaves remote_addr as the
# X-Forwarded-For is present and ProxyFix leaves remote_addr as the
# real 127.0.0.1 peer. A request routed through nginx instead carries the
# real client as the rightmost hop, so this gate can't be spoofed to 127.0.0.1.
remote = (request.remote_addr or '')
if remote not in ('127.0.0.1', '::1', 'localhost'):
return jsonify({'error': 'Ingest is restricted to localhost'}), 403
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
job_id = data.get('job_id')
if not job_id:
return jsonify({'error': 'job_id is required'}), 400
@@ -333,7 +334,7 @@ def preview_schedule():
existing cron read gate rather than the admin write gate — the Backups and
server-detail embedders shouldn't need admin to preview a schedule.
"""
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
schedule = data.get('schedule', '')
result = CronService.preview_schedule(schedule)
return jsonify(result)
+5 -4
View File
@@ -1,4 +1,4 @@
"""Dashboard boards API (plan 62) — /api/v1/dashboards.
"""Dashboard boards API — /api/v1/dashboards.
Per-user dashboard tabs and their widget layout. Everything here is scoped to
the calling user: a board that belongs to somebody else is indistinguishable
@@ -15,11 +15,12 @@ would be a broken dashboard the user cannot fix without an API call.
"""
import logging
from flask import Blueprint, jsonify, request
from flask import Blueprint, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.services import dashboard_service
from app.services.dashboard_service import GRID_COLS, MAX_WIDGETS_PER_BOARD
from app.api._request import json_object
logger = logging.getLogger(__name__)
@@ -138,7 +139,7 @@ def create_board():
"""Add a board for this user. ``widgets`` is optional — a new tab usually
starts empty and gets filled from the widget library."""
user_id = get_jwt_identity()
body = request.get_json(silent=True) or {}
body = json_object(optional=True)
name, err = _validate_name(body.get('name'))
if err:
@@ -169,7 +170,7 @@ def update_board(board_id):
if not board:
return jsonify({'error': 'Board not found'}), 404
body = request.get_json(silent=True) or {}
body = json_object(optional=True)
name = icon = position = widgets = None
if body.get('name') is not None:
+7 -29
View File
@@ -1,4 +1,4 @@
# Bucket: PER-APP (plan 29 #9). The per-app extension routes
# Bucket: PER-APP. The per-app extension routes
# (/<app_id>/extensions) address one installed engine, so they gate on
# can_access_app; the catalog reads above them are system-level and carry no
# app linkage, and every mutation is admin-only.
@@ -23,14 +23,13 @@ import logging
from flask import Blueprint, jsonify, request
from flask_jwt_extended import get_jwt_identity, jwt_required
from app.middleware.rbac import admin_required, get_current_user
from app.models import Application
from app.services.resource_grant_service import ResourceGrantService
from app.middleware.rbac import admin_required
from app.services import database_engine_extension_service as extensions
from app.services import database_engine_service as engines
from app.services.deployment_job_service import DeploymentJobService
from app.services.template_service import TemplateService
from app.exceptions import not_found, permission_denied
from app.api._access import require_app
from app.api._request import json_object
logger = logging.getLogger(__name__)
@@ -72,7 +71,7 @@ def install_database_engine():
so the caller navigates to the deploy console exactly as it would for any
other template install.
"""
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
plan = engines.plan_install(
template_id=data.get('template_id'),
@@ -127,25 +126,6 @@ def list_engine_extensions():
'family': TemplateService.EXTENSION_FAMILY}), 200
def _require_app_access(app_id):
"""Ensure the caller may see this app, mirroring the per-app docker DB read.
Returns an (error_body, status) tuple to return, or None when allowed. The
extension listing names an instance's image, its databases and what it can
load — all facts about someone's application, so a bare `@jwt_required()`
would have let any signed-in user enumerate them across the whole panel.
"""
user = get_current_user()
if not user:
return {'error': 'Unauthorized'}, 401
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not ResourceGrantService.can_access_app(user, app):
raise permission_denied()
return None
@database_engines_bp.route('/<int:app_id>/extensions', methods=['GET'])
@jwt_required()
def list_instance_extensions(app_id):
@@ -155,9 +135,7 @@ def list_instance_extensions(app_id):
inferring it from the image name -- slower (a docker exec per extension),
authoritative, and the only way to see what is already installed.
"""
denied = _require_app_access(app_id)
if denied:
return jsonify(denied[0]), denied[1]
require_app(app_id)
probe = request.args.get('probe', 'false').lower() == 'true'
result = extensions.instance_extensions(app_id, probe=probe)
@@ -178,7 +156,7 @@ def install_engine_extension(app_id):
gets "your image does not carry this, install X instead" rather than a raw
``could not open extension control file``.
"""
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
result = extensions.install_extension(
app_id,
extension_id=data.get('extension_id') or data.get('id'),
+28 -25
View File
@@ -1,4 +1,4 @@
# Bucket: PER-APP (plan 29 #9). The per-app docker DB read (/docker/app/<app_id>)
# Bucket: PER-APP. The per-app docker DB read (/docker/app/<app_id>)
# gates on can_access_app; the bare-container docker routes have no app linkage
# and are system-level admin-only, matching their kill/drop siblings.
# The host-level raw routes (engine lists, tables/structure, users/privileges,
@@ -16,6 +16,7 @@ from app.services.managed_database_service import ManagedDatabaseService
from app.middleware.rbac import admin_required, get_current_user, require_admin_user
from app.services.resource_grant_service import ResourceGrantService
from app.exceptions import field_required, not_found, permission_denied
from app.api._request import json_object
logger = logging.getLogger(__name__)
@@ -70,7 +71,7 @@ def list_mysql_databases():
@admin_required
def create_mysql_database():
"""Create a MySQL database."""
data = request.get_json()
data = json_object()
if not data or 'name' not in data:
raise field_required('name')
@@ -111,7 +112,7 @@ def create_mysql_database():
@admin_required
def drop_mysql_database(name):
"""Drop a MySQL database."""
data = request.get_json() or {}
data = json_object()
result = DatabaseService.mysql_drop_database(name, data.get('root_password'))
return jsonify(result), 200 if result['success'] else 400
@@ -132,7 +133,7 @@ def get_mysql_tables(name):
@admin_required
def backup_mysql_database(name):
"""Backup a MySQL database."""
data = request.get_json() or {}
data = json_object()
result = DatabaseService.mysql_backup(name, root_password=data.get('root_password'))
return jsonify(result), 200 if result['success'] else 400
@@ -141,7 +142,7 @@ def backup_mysql_database(name):
@admin_required
def restore_mysql_database(name):
"""Restore a MySQL database from backup."""
data = request.get_json()
data = json_object()
if not data or 'backup_path' not in data:
raise field_required('backup_path')
@@ -172,7 +173,7 @@ def list_mysql_users():
@admin_required
def create_mysql_user():
"""Create a MySQL user."""
data = request.get_json()
data = json_object()
if not data or 'username' not in data:
raise field_required('username')
@@ -206,7 +207,7 @@ def create_mysql_user():
@admin_required
def drop_mysql_user(username):
"""Drop a MySQL user."""
data = request.get_json() or {}
data = json_object()
host = data.get('host', 'localhost')
result = DatabaseService.mysql_drop_user(username, host, data.get('root_password'))
return jsonify(result), 200 if result['success'] else 400
@@ -229,7 +230,7 @@ def get_mysql_user_privileges(username):
@admin_required
def grant_mysql_privileges(username):
"""Grant privileges to a MySQL user."""
data = request.get_json()
data = json_object()
if not data or 'database' not in data:
raise field_required('database')
@@ -248,7 +249,7 @@ def grant_mysql_privileges(username):
@admin_required
def revoke_mysql_privileges(username):
"""Revoke privileges from a MySQL user."""
data = request.get_json()
data = json_object()
if not data or 'database' not in data:
raise field_required('database')
@@ -277,7 +278,7 @@ def list_pg_databases():
@admin_required
def create_pg_database():
"""Create a PostgreSQL database."""
data = request.get_json()
data = json_object()
if not data or 'name' not in data:
raise field_required('name')
@@ -330,7 +331,7 @@ def backup_pg_database(name):
@admin_required
def restore_pg_database(name):
"""Restore a PostgreSQL database from backup."""
data = request.get_json()
data = json_object()
if not data or 'backup_path' not in data:
raise field_required('backup_path')
@@ -353,7 +354,7 @@ def list_pg_users():
@admin_required
def create_pg_user():
"""Create a PostgreSQL user."""
data = request.get_json()
data = json_object()
if not data or 'username' not in data:
raise field_required('username')
@@ -388,7 +389,7 @@ def drop_pg_user(username):
@admin_required
def grant_pg_privileges(username):
"""Grant privileges to a PostgreSQL user."""
data = request.get_json()
data = json_object()
if not data or 'database' not in data:
raise field_required('database')
@@ -405,7 +406,7 @@ def grant_pg_privileges(username):
@admin_required
def revoke_pg_privileges(username):
"""Revoke privileges from a PostgreSQL user."""
data = request.get_json()
data = json_object()
if not data or 'database' not in data:
raise field_required('database')
@@ -450,7 +451,7 @@ def execute_mysql_query(name):
Security: Readonly mode is enforced by default. Admin role required to disable.
"""
data = request.get_json()
data = json_object()
if not data or 'query' not in data:
raise field_required('query')
@@ -486,7 +487,7 @@ def execute_pg_query(name):
Security: Readonly mode is enforced by default. Admin role required to disable.
"""
data = request.get_json()
data = json_object()
if not data or 'query' not in data:
raise field_required('query')
@@ -521,7 +522,7 @@ def execute_sqlite_query():
Security: Readonly mode is enforced by default. Admin role required to disable.
"""
data = request.get_json()
data = json_object()
if not data or 'path' not in data or 'query' not in data:
return jsonify({'error': 'path and query are required'}), 400
@@ -723,7 +724,7 @@ def execute_docker_query(container, database):
Security: Readonly mode is enforced by default. Admin role required to disable.
"""
data = request.get_json()
data = json_object()
if not data or 'query' not in data:
raise field_required('query')
@@ -774,7 +775,7 @@ def list_managed_databases():
@admin_required
def create_managed_database():
"""Provision a new database (mysql|postgresql) AND track it as managed."""
data = request.get_json() or {}
data = json_object()
engine = (data.get('engine') or '').strip().lower()
name = data.get('name')
if not name:
@@ -818,7 +819,7 @@ def create_managed_database():
@admin_required
def adopt_managed_database():
"""Track a live-discovered database that isn't tracked yet."""
data = request.get_json() or {}
data = json_object()
engine = (data.get('engine') or '').strip().lower()
name = data.get('name')
if not name or engine not in ('mysql', 'postgresql', 'mongodb'):
@@ -887,7 +888,7 @@ def protect_managed_database(managed_id):
managed = ManagedDatabaseService.get(managed_id)
if not managed:
raise not_found('database')
data = request.get_json() or {}
data = json_object()
policy = ManagedDatabaseService.protect(managed, fields=data.get('policy'))
return jsonify({'policy': policy.to_dict()}), 201
@@ -949,7 +950,7 @@ def list_docker_db_processes(container):
@admin_required
def kill_docker_db_process(container, pid):
"""Kill/terminate a process inside a Docker database container (admin only)."""
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
target = {
'engine': data.get('type') or request.args.get('type', 'mysql'),
'container': container,
@@ -963,7 +964,7 @@ def kill_docker_db_process(container, pid):
return jsonify(result), 200
# ==================== INSIGHTS (plan 86 §A3) ====================
# ==================== INSIGHTS ====================
# Read-only top queries + connection / cache-hit gauges for a Docker database
# container, and the one write it offers: turning pg_stat_statements on
# (through the config tuner, so its rollback undoes it).
@@ -992,7 +993,7 @@ def docker_db_insights(container):
@admin_required
def enable_pg_stat_statements(container):
from app.services.db_config_tuner_service import DbConfigTunerService
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
target = _docker_db_target(container, dict(data, type='postgresql'))
return jsonify(DbConfigTunerService.enable_pg_stat_statements(target)), 200
@@ -1003,6 +1004,8 @@ def enable_pg_stat_statements(container):
@jwt_required()
def generate_password():
"""Generate a secure random password."""
length = request.args.get('length', 16, type=int)
# Bounded: an unchecked ?length=1000000000 made the server build a
# gigabyte-long string.
length = min(max(request.args.get('length', 16, type=int), 8), 128)
password = DatabaseService.generate_password(length)
return jsonify({'password': password}), 200
+3 -2
View File
@@ -20,6 +20,7 @@ from flask import Blueprint, request, jsonify
from app.middleware.rbac import admin_required
from app.services.db_config_tuner_service import DbConfigTunerService
from app.api._request import json_object
db_tuner_bp = Blueprint('db_tuner', __name__)
@@ -72,7 +73,7 @@ def inspect_target(target):
@admin_required
def apply_target(target):
"""Apply an explicit operator-chosen settings dict (restarts the engine)."""
data = request.get_json() or {}
data = json_object()
if not data.get('settings'):
return jsonify({'error': 'settings is required'}), 400
resolved, err = _build_target(target, data)
@@ -85,7 +86,7 @@ def apply_target(target):
@admin_required
def rollback_target(target):
"""Restore the pre-apply config and restart the engine."""
data = request.get_json() or {}
data = json_object()
resolved, err = _build_target(target, data)
if err:
return err
+4 -2
View File
@@ -2,6 +2,7 @@ from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required
from app.services.ddns_service import DdnsService
from app.middleware.rbac import get_current_user
from app.api._request import json_object
ddns_bp = Blueprint('ddns', __name__)
@@ -25,8 +26,9 @@ def list_hosts():
def create_host():
if not _admin():
return jsonify({'error': 'Admin access required'}), 403
data = json_object()
try:
host = DdnsService.create_host(request.get_json() or {})
host = DdnsService.create_host(data)
# Return the token once, on creation — it is masked everywhere else.
return jsonify(host.to_dict(include_token=True)), 201
except ValueError as e:
@@ -68,7 +70,7 @@ def update():
"""Called by a router or cron when its public IP changes. There is no JWT
here — the per-host token is the credential. The IP can be passed explicitly
(?ip=) or inferred from the request source address."""
body = request.get_json(silent=True) or {}
body = json_object(optional=True)
token = request.args.get('token') or request.headers.get('X-DDNS-Token') or body.get('token')
if not token:
return jsonify({'error': 'token required'}), 401
+7 -6
View File
@@ -1,4 +1,4 @@
# Bucket: PER-APP (plan 29 #9). Deploy config reads/writes gate on the shared
# Bucket: PER-APP. Deploy config reads/writes gate on the shared
# app-access seam (can_access_app / can_edit_app); the public inbound webhook is
# token/signature-verified (ALLOWLISTed — no per-app user session).
import os
@@ -11,6 +11,7 @@ from app.models import User, Application
from app.services.git_service import GitService
from app.services.resource_grant_service import ResourceGrantService
from app.exceptions import not_found, permission_denied
from app.api._request import json_object
deploy_bp = Blueprint('deploy', __name__)
@@ -47,7 +48,7 @@ def configure_deployment(app_id):
if not app:
raise not_found('service')
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -89,7 +90,7 @@ def trigger_deploy(app_id):
if not ResourceGrantService.can_edit_app(user, app):
raise permission_denied()
data = request.get_json() or {}
data = json_object()
result = GitService.deploy(app_id, force=data.get('force', False))
return jsonify(result), 200 if result['success'] else 400
@@ -109,7 +110,7 @@ def pull_changes(app_id):
if not ResourceGrantService.can_edit_app(user, app):
raise permission_denied()
data = request.get_json() or {}
data = json_object()
result = GitService.pull_changes(app.root_path, data.get('branch'))
return jsonify(result), 200 if result['success'] else 400
@@ -180,7 +181,7 @@ def get_deployment_history():
@admin_required
def clone_repository():
"""Clone a Git repository to a new location."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -229,7 +230,7 @@ def get_branches(app_id):
@developer_required
def get_branches_from_url():
"""Get list of branches from a repository URL (before cloning)."""
data = request.get_json()
data = json_object()
if not data or 'repo_url' not in data:
return jsonify({'error': 'repo_url is required'}), 400
+6 -5
View File
@@ -1,4 +1,4 @@
# Bucket: PER-APP (plan 29 #9). App-linked job reads gate on the app-grant
# Bucket: PER-APP. App-linked job reads gate on the app-grant
# seam (can_access_app); retries require can_operate_app. App-less jobs
# (template installs, simulated deploys) are requester-or-admin only. The
# job list is admin-only globally and app-access-gated when app_id is given.
@@ -13,6 +13,7 @@ from app.models.deployment_job import DeploymentJob, DeploymentJobLog
from app.services.deployment_job_service import DeploymentJobService
from app.services.resource_grant_service import ResourceGrantService
from app.exceptions import not_found, permission_denied
from app.api._request import json_object
deployment_jobs_bp = Blueprint('deployment_jobs', __name__)
@@ -51,7 +52,7 @@ def _job_payload(job, user, include_plan=False, include_logs=False):
@deployment_jobs_bp.route('/simulate', methods=['GET'])
@jwt_required()
def get_simulate_info():
"""Simulated-deploy availability + scenario catalog (plan 51.5 dev tool).
"""Simulated-deploy availability + scenario catalog (dev tool).
404 when the DEMO_DEPLOYS_ENABLED gate is off (production default), so the
surface simply doesn't exist outside dev.
@@ -78,7 +79,7 @@ def simulate_deployment():
from flask_jwt_extended import get_jwt_identity
from app.services.demo_deploy_service import DemoDeployService
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
wait = request.args.get('wait', 'false').lower() == 'true'
result = DemoDeployService.create(
scenario=data.get('scenario') or '',
@@ -152,8 +153,8 @@ def get_deployment_job(job_id):
@deployment_jobs_bp.route('/<job_id>/retry', methods=['POST'])
@jwt_required()
def retry_deployment_job(job_id):
"""Retry a failed deployment job by cloning it and enqueuing a fresh run
(plan 51 D8). Only failed jobs may be retried."""
"""Retry a failed deployment job by cloning it and enqueuing a fresh run.
Only failed jobs may be retried."""
job = DeploymentJob.query.get(job_id)
if not job:
raise not_found('deployment')
+6 -22
View File
@@ -1,6 +1,6 @@
# Bucket: PER-APP read (plan 29 #9). Per-app history gates via _access →
# Bucket: PER-APP read. Per-app history gates via _access →
# can_access_app. See docs/WORKSPACE_SCOPING.md.
"""Unified deployment surface (§3 unification).
"""Unified deployment surface.
One read namespace over the deploy records that today live in three places:
versioned build ``Deployment`` rows, webhook-triggered ``GitDeployment`` rows,
@@ -12,35 +12,21 @@ history instead of merging two sources.
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required
from app.models import Application
from app.models.deployment import Deployment
from app.models.webhook import GitDeployment
from app.middleware.rbac import get_current_user
from app.exceptions import not_found, permission_denied
from app.exceptions import not_found
from app.api._access import require_app
deployments_bp = Blueprint('deployments', __name__)
def _access(app_id):
from app.services.resource_grant_service import ResourceGrantService
user = get_current_user()
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not ResourceGrantService.can_access_app(user, app):
raise permission_denied()
return app, None
@deployments_bp.route('/apps/<int:app_id>/history', methods=['GET'])
@jwt_required()
def app_history(app_id):
"""Federated deploy history for an app: versioned build Deployments +
webhook GitDeployments, each tagged with ``source``, newest first.
Query: ?source=build|webhook|all (default all), ?limit= (<=500)."""
app, err = _access(app_id)
if err:
return err
app = require_app(app_id)
source = (request.args.get('source') or 'all').lower()
try:
limit = min(int(request.args.get('limit', 100)), 500)
@@ -72,7 +58,5 @@ def get_deployment(deployment_id):
d = Deployment.query.get(deployment_id)
if not d:
raise not_found('deployment')
_, err = _access(d.app_id)
if err:
return err
require_app(d.app_id)
return jsonify({'deployment': d.to_dict(include_logs=True)}), 200
+6 -5
View File
@@ -6,7 +6,7 @@ public resolvers, and revert — restoring the pre-cutover world byte-for-byte.
Read verbs (ttl-guidance, list/get snapshots, verify) are open to any
authenticated user; the mutating verbs (snapshot, cutover, revert) are
admin-only (plan 31 #1/#2/#3).
admin-only.
"""
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required
@@ -14,6 +14,7 @@ from flask_jwt_extended import jwt_required
from app.services.dns_cutover_service import DnsCutoverService, DnsCutoverError
from app.middleware.rbac import require_admin_user
from app.exceptions import not_found
from app.api._request import json_object
dns_cutover_bp = Blueprint('dns_cutover', __name__)
@@ -30,7 +31,7 @@ def _cutover_error(exc):
@dns_cutover_bp.route('/ttl-guidance', methods=['POST'])
@jwt_required()
def ttl_guidance():
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
return jsonify(DnsCutoverService.ttl_guidance(data.get('records') or []))
@@ -38,7 +39,7 @@ def ttl_guidance():
@jwt_required()
def create_snapshot():
require_admin_user()
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
try:
snapshot = DnsCutoverService.create_snapshot(
domain=data.get('domain'),
@@ -71,7 +72,7 @@ def get_snapshot(snapshot_id):
@jwt_required()
def cutover():
require_admin_user()
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
snapshot_id = data.get('snapshot_id')
if not snapshot_id:
return jsonify({'error': 'snapshot_id is required — a cutover needs a '
@@ -92,7 +93,7 @@ def cutover():
@dns_cutover_bp.route('/verify', methods=['POST'])
@jwt_required()
def verify():
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
try:
result = DnsCutoverService.verify(
domain=data.get('domain'),
+3 -2
View File
@@ -6,10 +6,11 @@ still be able to connect a DNS provider. They keep the historical
/api/v1/email/dns-providers paths (the routes originally lived in the email
API before the extraction) so existing frontends keep working.
"""
from flask import Blueprint, request, jsonify
from flask import Blueprint, jsonify
from app.middleware.rbac import admin_required, viewer_required
from app.services.dns_provider_service import DNSProviderService
from app.api._request import json_object
dns_providers_bp = Blueprint('dns_providers', __name__)
@@ -26,7 +27,7 @@ def list_dns_providers():
@admin_required
def add_dns_provider():
"""Add a DNS provider."""
data = request.get_json()
data = json_object()
if not data or not data.get('name') or not data.get('provider') or not data.get('api_key'):
return jsonify({'success': False, 'error': 'Name, provider, and api_key are required'}), 400
result = DNSProviderService.add_provider(
+7 -6
View File
@@ -2,6 +2,7 @@ from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required
from app.services.dns_zone_service import DNSZoneService
from app.middleware.rbac import get_current_user
from app.api._request import json_object
dns_zones_bp = Blueprint('dns_zones', __name__)
@@ -30,7 +31,7 @@ def adopt_zone():
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json() or {}
data = json_object()
try:
zone = DNSZoneService.adopt_zone(
data.get('domain'), data.get('dns_provider_config_id'))
@@ -73,7 +74,7 @@ def create_zone():
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json()
data = json_object()
try:
zone = DNSZoneService.create_zone(data)
return jsonify(zone.to_dict()), 201
@@ -107,7 +108,7 @@ def create_record(zone_id):
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json()
data = json_object()
try:
record = DNSZoneService.create_record(zone_id, data)
return jsonify(record.to_dict()), 201
@@ -121,7 +122,7 @@ def update_record(record_id):
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json()
data = json_object()
record = DNSZoneService.update_record(record_id, data)
if not record:
return jsonify({'error': 'Record not found'}), 404
@@ -153,7 +154,7 @@ def apply_preset(zone_id):
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json() or {}
data = json_object()
preset_key = data.get('preset')
variables = data.get('variables', {})
try:
@@ -233,7 +234,7 @@ def import_zone(zone_id):
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json() or {}
data = json_object()
content = data.get('zone_file', '')
try:
records = DNSZoneService.import_zone(zone_id, content)
+26 -25
View File
@@ -5,6 +5,7 @@ from app.services.docker_service import DockerService
from app.middleware.rbac import admin_required
from app import db, paths
from app.error_reporting import unexpected_response
from app.api._request import json_object
docker_bp = Blueprint('docker', __name__)
@@ -65,7 +66,7 @@ def get_container(container_id):
@admin_required
def create_container():
"""Create a new container."""
data = request.get_json()
data = json_object()
if not data or 'image' not in data:
return jsonify({'error': 'image is required'}), 400
@@ -88,7 +89,7 @@ def create_container():
@admin_required
def run_container():
"""Run a new container (create and start)."""
data = request.get_json()
data = json_object()
if not data or 'image' not in data:
return jsonify({'error': 'image is required'}), 400
@@ -136,7 +137,7 @@ def stop_container(container_id):
blocked = _reject_if_protected(container_id)
if blocked:
return blocked
data = request.get_json() or {}
data = json_object()
timeout = data.get('timeout', 10)
result = DockerService.stop_container(container_id, timeout)
return jsonify(result), 200 if result['success'] else 400
@@ -149,7 +150,7 @@ def restart_container(container_id):
blocked = _reject_if_protected(container_id)
if blocked:
return blocked
data = request.get_json() or {}
data = json_object()
timeout = data.get('timeout', 10)
result = DockerService.restart_container(container_id, timeout)
return jsonify(result), 200 if result['success'] else 400
@@ -163,7 +164,7 @@ def remove_container(container_id):
if blocked:
return blocked
data = request.get_json() or {}
data = json_object()
force = data.get('force', False)
volumes = data.get('volumes', False)
result = DockerService.remove_container(container_id, force, volumes)
@@ -187,7 +188,7 @@ def get_container_logs(container_id):
@jwt_required()
def get_containers_stats():
"""Get resource stats for multiple containers."""
data = request.get_json() or {}
data = json_object()
container_ids = data.get('ids') or data.get('container_ids') or []
if not isinstance(container_ids, list):
@@ -211,7 +212,7 @@ def get_container_stats(container_id):
@admin_required
def exec_container(container_id):
"""Execute a command in a container."""
data = request.get_json()
data = json_object()
if not data or 'command' not in data:
return jsonify({'error': 'command is required'}), 400
@@ -234,7 +235,7 @@ def list_images():
@admin_required
def pull_image():
"""Pull an image from registry."""
data = request.get_json()
data = json_object()
if not data or 'image' not in data:
return jsonify({'error': 'image is required'}), 400
@@ -250,7 +251,7 @@ def pull_image():
@admin_required
def remove_image(image_id):
"""Remove an image."""
data = request.get_json() or {}
data = json_object()
force = data.get('force', False)
result = DockerService.remove_image(image_id, force)
return jsonify(result), 200 if result['success'] else 400
@@ -260,7 +261,7 @@ def remove_image(image_id):
@admin_required
def build_image():
"""Build an image from Dockerfile."""
data = request.get_json()
data = json_object()
if not data or 'path' not in data or 'tag' not in data:
return jsonify({'error': 'path and tag are required'}), 400
@@ -278,7 +279,7 @@ def build_image():
@admin_required
def tag_image():
"""Tag an image."""
data = request.get_json()
data = json_object()
if not data or 'source' not in data or 'target' not in data:
return jsonify({'error': 'source and target are required'}), 400
@@ -301,7 +302,7 @@ def list_networks():
@admin_required
def create_network():
"""Create a network."""
data = request.get_json()
data = json_object()
if not data or 'name' not in data:
return jsonify({'error': 'name is required'}), 400
@@ -335,7 +336,7 @@ def list_volumes():
@admin_required
def create_volume():
"""Create a volume."""
data = request.get_json()
data = json_object()
if not data or 'name' not in data:
return jsonify({'error': 'name is required'}), 400
@@ -351,7 +352,7 @@ def create_volume():
@admin_required
def remove_volume(volume_name):
"""Remove a volume."""
data = request.get_json() or {}
data = json_object()
force = data.get('force', False)
result = DockerService.remove_volume(volume_name, force)
return jsonify(result), 200 if result['success'] else 400
@@ -371,7 +372,7 @@ def compose_list():
@admin_required
def compose_up():
"""Start Docker Compose services."""
data = request.get_json()
data = json_object()
if not data or 'path' not in data:
return jsonify({'error': 'path is required'}), 400
@@ -388,7 +389,7 @@ def compose_up():
@admin_required
def compose_down():
"""Stop Docker Compose services."""
data = request.get_json()
data = json_object()
if not data or 'path' not in data:
return jsonify({'error': 'path is required'}), 400
@@ -405,7 +406,7 @@ def compose_down():
@admin_required
def compose_ps():
"""List Docker Compose services."""
data = request.get_json()
data = json_object()
if not data or 'path' not in data:
return jsonify({'error': 'path is required'}), 400
@@ -418,7 +419,7 @@ def compose_ps():
@admin_required
def compose_logs():
"""Get Docker Compose logs."""
data = request.get_json()
data = json_object()
if not data or 'path' not in data:
return jsonify({'error': 'path is required'}), 400
@@ -435,7 +436,7 @@ def compose_logs():
@admin_required
def compose_restart():
"""Restart Docker Compose services."""
data = request.get_json()
data = json_object()
if not data or 'path' not in data:
return jsonify({'error': 'path is required'}), 400
@@ -463,7 +464,7 @@ def docker_cleanup():
- Images in use
"""
data = request.get_json() or {}
data = json_object()
include_volumes = data.get('volumes', False)
results = {
@@ -623,7 +624,7 @@ def cleanup_all_apps():
@admin_required
def compose_pull():
"""Pull Docker Compose images."""
data = request.get_json()
data = json_object()
if not data or 'path' not in data:
return jsonify({'error': 'path is required'}), 400
@@ -636,7 +637,7 @@ def compose_pull():
@admin_required
def compose_validate():
"""Validate a Docker Compose file."""
data = request.get_json()
data = json_object()
if not data or 'path' not in data:
return jsonify({'error': 'path is required'}), 400
@@ -649,7 +650,7 @@ def compose_validate():
@admin_required
def compose_config():
"""Get parsed Docker Compose configuration."""
data = request.get_json()
data = json_object()
if not data or 'path' not in data:
return jsonify({'error': 'path is required'}), 400
@@ -664,7 +665,7 @@ def compose_config():
@admin_required
def prune_system():
"""Remove unused Docker resources."""
data = request.get_json() or {}
data = json_object()
result = DockerService.prune_system(
data.get('all', False),
data.get('volumes', False)
@@ -678,7 +679,7 @@ def prune_system():
@admin_required
def create_docker_app():
"""Create a Docker-based application."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
+25 -43
View File
@@ -13,11 +13,11 @@ Contract (the CLI codes against this too):
{'items': [{kind, type?, id?, name?}]}
"""
from flask import Blueprint, jsonify, request
from app.error_reporting import unexpected_response
from ..middleware.rbac import admin_required
from ..services.doctor_service import DOCTOR_REPAIR_JOB_KIND, DoctorService
from ..services.drift_service import DRIFT_JOB_KIND, DriftService
from app.api._request import json_object
doctor_bp = Blueprint('doctor', __name__)
@@ -30,38 +30,29 @@ doctor_bp = Blueprint('doctor', __name__)
@admin_required
def get_drift_report():
"""Last stored drift report (null when no sweep has run yet)."""
try:
return jsonify({'report': DriftService.get_last_report()})
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)
return jsonify({'report': DriftService.get_last_report()})
@doctor_bp.route('/drift/check', methods=['POST'])
@admin_required
def run_drift_check():
"""Enqueue a one-off drift sweep job."""
try:
from app.jobs.service import JobService
job = JobService.enqueue(DRIFT_JOB_KIND, payload={}, max_attempts=1)
return jsonify({'job_id': job.id}), 202
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)
from app.jobs.service import JobService
job = JobService.enqueue(DRIFT_JOB_KIND, payload={}, max_attempts=1)
return jsonify({'job_id': job.id}), 202
@doctor_bp.route('/drift/<check_type>/<resource_id>/repair', methods=['POST'])
@admin_required
def repair_drift(check_type, resource_id):
"""Repair one drifted resource. Explicit confirmation required."""
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
if data.get('confirm') is not True:
return jsonify({'error': 'Confirmation required: pass {"confirm": true}.'}), 400
try:
rid = int(resource_id) if resource_id.isdigit() else resource_id
result = DriftService.repair(check_type, rid)
status = 200 if result.get('success') else 400
return jsonify(result), status
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)
rid = int(resource_id) if resource_id.isdigit() else resource_id
result = DriftService.repair(check_type, rid)
status = 200 if result.get('success') else 400
return jsonify(result), status
# --------------------------------------------------------------------------- #
@@ -73,10 +64,7 @@ def repair_drift(check_type, resource_id):
@admin_required
def get_doctor_report():
"""Last stored doctor report (null when the doctor has never run)."""
try:
return jsonify({'report': DoctorService.get_last_report()})
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)
return jsonify({'report': DoctorService.get_last_report()})
@doctor_bp.route('/run', methods=['POST'])
@@ -84,11 +72,8 @@ def get_doctor_report():
def run_doctor():
"""Run the sweep synchronously (the doctor is interactive; every internal
probe is time-capped) and return the fresh report."""
try:
report = DoctorService.run()
return jsonify({'report': report})
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)
report = DoctorService.run()
return jsonify({'report': report})
@doctor_bp.route('/repair', methods=['POST'])
@@ -99,21 +84,18 @@ def repair_items():
Synchronous remains the default for existing CLI callers. The web shell
asks for ``wait=false`` and follows the returned job in Operations.
"""
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
items = data.get('items')
if not isinstance(items, list) or not items:
return jsonify({'error': "Body must carry a non-empty 'items' list."}), 400
try:
if request.args.get('wait', 'true').lower() == 'false':
from app.jobs.service import JobService
job = JobService.enqueue(
DOCTOR_REPAIR_JOB_KIND,
payload={'items': items},
max_attempts=1,
owner_type='doctor',
owner_id='host',
)
return jsonify({'job_id': job.id, 'kind': job.kind}), 202
return jsonify({'results': DoctorService.repair(items)})
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)
if request.args.get('wait', 'true').lower() == 'false':
from app.jobs.service import JobService
job = JobService.enqueue(
DOCTOR_REPAIR_JOB_KIND,
payload={'items': items},
max_attempts=1,
owner_type='doctor',
owner_id='host',
)
return jsonify({'job_id': job.id, 'kind': job.kind}), 202
return jsonify({'results': DoctorService.repair(items)})
+6 -5
View File
@@ -1,4 +1,4 @@
# Bucket: PER-APP (plan 29 #9). Per-app domain routes gate on the shared
# Bucket: PER-APP. Per-app domain routes gate on the shared
# app-access seam (can_access_app for reads, can_edit_app for mutations);
# host-level DNS/nginx surfaces stay admin-only.
import re
@@ -11,6 +11,7 @@ from app.services.nginx_service import NginxService
from app.services.ssl_service import SSLService, get_acme_contact, remember_acme_contact
from app.services.resource_grant_service import ResourceGrantService
from app.exceptions import already_exists, not_found, permission_denied
from app.api._request import json_object
domains_bp = Blueprint('domains', __name__)
@@ -146,7 +147,7 @@ def get_domain(domain_id):
def create_domain():
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -278,7 +279,7 @@ def give_subdomain():
publish under a specific registered base domain."""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
data = request.get_json() or {}
data = json_object()
app = (Application.query_active().filter_by(id=data.get('application_id')).first()
if data.get('application_id') else None)
if not app:
@@ -307,7 +308,7 @@ def update_domain(domain_id):
if not ResourceGrantService.can_edit_app(user, app):
raise permission_denied()
data = request.get_json()
data = json_object()
if 'is_primary' in data and data['is_primary']:
# Unset any existing primary domain for this app
@@ -386,7 +387,7 @@ def enable_ssl(domain_id):
if not ResourceGrantService.can_edit_app(user, app):
raise permission_denied()
data = request.get_json() or {}
data = json_object()
# An address on the request wins; otherwise the panel-wide ACME contact,
# so the modal stops asking for the same address on every certificate.
email = get_acme_contact(data.get('email'))
+28 -66
View File
@@ -1,4 +1,4 @@
# Bucket: PER-APP (plan 29 #9). Env-var routes converge on the app-access seam:
# Bucket: PER-APP. Env-var routes converge on the app-access seam:
# reads use can_access_app (owner/admin/any grant — a viewer grant can read,
# with secrets still masked), writes use can_edit_app (owner/admin/editor grant).
"""
@@ -10,46 +10,25 @@ All values are encrypted at rest.
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.models import Application, User
from app.services.env_service import EnvService
from app.services.resource_grant_service import ResourceGrantService
from app.exceptions import not_found, permission_denied
from app.api._access import require_app
from app.middleware.rbac import get_current_user
from app.api._request import json_object
env_vars_bp = Blueprint('env_vars', __name__)
def check_app_access(app_id, user_id, write=False):
"""Check that the user may access the application's env vars (plan 29 #9).
Reads (``write=False``) require can_access_app (owner/admin/any grant);
writes require can_edit_app (owner/admin/editor grant — a viewer grant is
read-only). Returns ``(user, app, None, None)`` on success or
``(None, None, error_response, status)`` on failure."""
user = User.query.get(user_id)
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
ok = (ResourceGrantService.can_edit_app(user, app) if write
else ResourceGrantService.can_access_app(user, app))
if not ok:
raise permission_denied()
return user, app, None, None
@env_vars_bp.route('/<int:app_id>/env', methods=['GET'])
@jwt_required()
def get_env_vars(app_id):
"""Get all environment variables for an application."""
current_user_id = get_jwt_identity()
user, app, error, status = check_app_access(app_id, current_user_id)
if error:
return error, status
user = get_current_user()
app = require_app(app_id)
# Check if secrets should be masked
mask_secrets = request.args.get('mask', 'false').lower() == 'true'
# A read-only grant never sees secret values, whatever ?mask says.
mask_secrets = (request.args.get('mask', 'false').lower() == 'true'
or not ResourceGrantService.can_edit_app(user, app))
env_vars = EnvService.get_env_vars(app_id, mask_secrets=mask_secrets)
@@ -64,11 +43,9 @@ def get_env_vars(app_id):
def create_env_var(app_id):
"""Create a new environment variable."""
current_user_id = get_jwt_identity()
user, app, error, status = check_app_access(app_id, current_user_id, write=True)
if error:
return error, status
app = require_app(app_id, write=True)
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -100,10 +77,8 @@ def create_env_var(app_id):
@jwt_required()
def get_env_var(app_id, key):
"""Get a single environment variable by key."""
current_user_id = get_jwt_identity()
user, app, error, status = check_app_access(app_id, current_user_id)
if error:
return error, status
user = get_current_user()
app = require_app(app_id)
env_var = EnvService.get_env_var(app_id, key)
@@ -111,7 +86,8 @@ def get_env_var(app_id, key):
return jsonify({'error': 'Environment variable not found'}), 404
return jsonify({
'env_var': env_var.to_dict()
'env_var': env_var.to_dict(
mask_secrets=not ResourceGrantService.can_edit_app(user, app))
}), 200
@@ -120,11 +96,9 @@ def get_env_var(app_id, key):
def update_env_var(app_id, key):
"""Update an existing environment variable."""
current_user_id = get_jwt_identity()
user, app, error, status = check_app_access(app_id, current_user_id, write=True)
if error:
return error, status
app = require_app(app_id, write=True)
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -157,9 +131,7 @@ def update_env_var(app_id, key):
def delete_env_var(app_id, key):
"""Delete an environment variable."""
current_user_id = get_jwt_identity()
user, app, error, status = check_app_access(app_id, current_user_id, write=True)
if error:
return error, status
app = require_app(app_id, write=True)
success, err = EnvService.delete_env_var(app_id, key, current_user_id)
@@ -177,11 +149,9 @@ def delete_env_var(app_id, key):
def bulk_set_env_vars(app_id):
"""Set multiple environment variables at once."""
current_user_id = get_jwt_identity()
user, app, error, status = check_app_access(app_id, current_user_id, write=True)
if error:
return error, status
app = require_app(app_id, write=True)
data = request.get_json()
data = json_object()
if not data or 'env_vars' not in data:
return jsonify({'error': 'env_vars object is required'}), 400
@@ -201,11 +171,9 @@ def bulk_set_env_vars(app_id):
def import_env_file(app_id):
"""Import environment variables from .env file content."""
current_user_id = get_jwt_identity()
user, app, error, status = check_app_access(app_id, current_user_id, write=True)
if error:
return error, status
app = require_app(app_id, write=True)
data = request.get_json()
data = json_object()
if not data or 'content' not in data:
return jsonify({'error': 'content is required'}), 400
@@ -241,12 +209,11 @@ def import_env_file(app_id):
@jwt_required()
def export_env_file(app_id):
"""Export environment variables as .env file content."""
current_user_id = get_jwt_identity()
user, app, error, status = check_app_access(app_id, current_user_id)
if error:
return error, status
user = get_current_user()
app = require_app(app_id)
include_secrets = request.args.get('include_secrets', 'true').lower() == 'true'
include_secrets = (request.args.get('include_secrets', 'true').lower() == 'true'
and ResourceGrantService.can_edit_app(user, app))
content = EnvService.export_to_env_format(app_id, include_secrets=include_secrets)
@@ -260,10 +227,7 @@ def export_env_file(app_id):
@jwt_required()
def get_env_history(app_id):
"""Get change history for environment variables."""
current_user_id = get_jwt_identity()
user, app, error, status = check_app_access(app_id, current_user_id)
if error:
return error, status
app = require_app(app_id)
limit = request.args.get('limit', 50, type=int)
history = EnvService.get_history(app_id, limit=limit)
@@ -279,9 +243,7 @@ def get_env_history(app_id):
def clear_all_env_vars(app_id):
"""Delete all environment variables for an application."""
current_user_id = get_jwt_identity()
user, app, error, status = check_app_access(app_id, current_user_id, write=True)
if error:
return error, status
app = require_app(app_id, write=True)
count = EnvService.clear_all(app_id, current_user_id)
+7 -16
View File
@@ -3,35 +3,26 @@
Environments are children of a project; access is derived from the parent
project's workspace, mirroring projects.py.
"""
from flask import Blueprint, request, jsonify
from flask import Blueprint, jsonify
from flask_jwt_extended import jwt_required
from app.services.project_service import ProjectService
from app.services.workspace_service import WorkspaceService
from app.middleware.rbac import get_current_user
from app.exceptions import permission_denied
from app.api._access import visible_workspace_ids
from app.api._request import json_object
environments_bp = Blueprint('environments', __name__)
def _accessible_workspace_ids(user):
ws_id = WorkspaceService.resolve_workspace_id(
user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id'))
if ws_id is not None:
return [ws_id]
if user.is_admin:
from app.models.workspace import Workspace
return [w.id for w in Workspace.query.all()]
return [w.id for w in WorkspaceService.list_workspaces(user_id=user.id)]
def _project_for_write(user, project_id):
"""Return (project, error_response). error_response is a (json, status) tuple
when the project can't be written by this user, else None."""
project = ProjectService.get_project(project_id)
if not project:
return None, (jsonify({'error': 'Project not found'}), 404)
if not user.is_admin and project.workspace_id not in set(_accessible_workspace_ids(user)):
if not user.is_admin and project.workspace_id not in set(visible_workspace_ids(user)):
raise permission_denied()
if not WorkspaceService.can_write_in_workspace(user, project.workspace_id):
return None, (jsonify({'error': 'You have read-only access to this workspace'}), 403)
@@ -44,7 +35,7 @@ def _project_for_write(user, project_id):
def create_environment():
"""Create an environment under a project. Body: {project_id, name, is_default?}."""
user = get_current_user()
data = request.get_json() or {}
data = json_object()
project_id = data.get('project_id')
if not project_id:
return jsonify({'error': 'project_id is required'}), 400
@@ -77,7 +68,7 @@ def update_environment(environment_id):
if err:
return err
data = request.get_json() or {}
data = json_object()
updated = ProjectService.update_environment(
environment_id, name=data.get('name'), is_default=data.get('is_default'))
return jsonify({'message': 'Environment updated', 'environment': updated.to_dict(include_counts=True)}), 200
@@ -107,7 +98,7 @@ def delete_environment(environment_id):
def reorder_environments():
"""Reorder a project's environments. Body: {project_id, ordered_ids:[...]}"""
user = get_current_user()
data = request.get_json() or {}
data = json_object()
project_id = data.get('project_id')
ordered_ids = data.get('ordered_ids')
if not project_id:
+2 -1
View File
@@ -8,6 +8,7 @@ from flask_jwt_extended import get_jwt_identity, verify_jwt_in_request
from app.api._query import PageQuery
from app.middleware.rbac import admin_required
from app.services import error_log_service
from app.api._request import json_object
error_logs_bp = Blueprint('error_logs', __name__)
@@ -79,7 +80,7 @@ def get_error_log(error_id):
@admin_required
def resolve_error_log(error_id):
"""Set the resolved flag: body {"resolved": true|false}."""
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
if not isinstance(data.get('resolved'), bool):
return jsonify({'error': 'resolved (boolean) is required'}), 400
entry = error_log_service.set_resolved(error_id, data['resolved'])
+14 -16
View File
@@ -6,6 +6,7 @@ from app.exceptions import NotFoundError, PermissionDeniedError, ValidationError
from ..middleware.rbac import permission_required
from ..services.file_service import FileService
from ..services.storage_provider_service import StorageProviderService
from app.api._request import json_object
import os
import tempfile
@@ -61,7 +62,7 @@ def read_file():
@permission_required('files', 'write')
def write_file():
"""Write content to a file."""
data = request.get_json()
data = json_object()
if not data:
raise ValidationError('Request body is required')
@@ -85,7 +86,7 @@ def write_file():
@permission_required('files', 'write')
def create_file():
"""Create a new file."""
data = request.get_json()
data = json_object()
if not data:
raise ValidationError('Request body is required')
@@ -105,7 +106,7 @@ def create_file():
@permission_required('files', 'write')
def create_directory():
"""Create a new directory."""
data = request.get_json()
data = json_object()
if not data:
raise ValidationError('Request body is required')
@@ -138,7 +139,7 @@ def delete_path():
@permission_required('files', 'write')
def rename_path():
"""Rename a file or directory."""
data = request.get_json()
data = json_object()
if not data:
raise ValidationError('Request body is required')
@@ -158,7 +159,7 @@ def rename_path():
@permission_required('files', 'write')
def copy_path():
"""Copy a file or directory."""
data = request.get_json()
data = json_object()
if not data:
raise ValidationError('Request body is required')
@@ -178,7 +179,7 @@ def copy_path():
@permission_required('files', 'write')
def move_path():
"""Move a file or directory."""
data = request.get_json()
data = json_object()
if not data:
raise ValidationError('Request body is required')
@@ -198,7 +199,7 @@ def move_path():
@permission_required('files', 'write')
def change_permissions():
"""Change file/directory permissions."""
data = request.get_json()
data = json_object()
if not data:
raise ValidationError('Request body is required')
@@ -293,14 +294,11 @@ def download_file():
if os.path.isdir(path):
return jsonify({'error': 'Cannot download directory'}), 400
try:
return send_file(
path,
as_attachment=True,
download_name=os.path.basename(path)
)
except Exception as e: # noqa: BLE001 - reported, not swallowed
return unexpected_response(e)
return send_file(
path,
as_attachment=True,
download_name=os.path.basename(path)
)
@files_bp.route('/upload', methods=['POST'])
@@ -390,7 +388,7 @@ def s3_read():
@permission_required('files', 'write')
def s3_write():
"""Write (create or overwrite) an object from text content."""
data = request.get_json() or {}
data = json_object()
path = data.get('path')
content = data.get('content')
if not path:
+13 -12
View File
@@ -5,6 +5,7 @@ from flask import Blueprint, request, jsonify
from ..middleware.rbac import admin_required, viewer_required
from ..services.firewall_service import FirewallService
from ..services.metadata_guard_service import MetadataGuardService, SETTING_KEY
from app.api._request import json_object
firewall_bp = Blueprint('firewall', __name__)
@@ -40,7 +41,7 @@ def enable_firewall():
so this is the only thing standing between a click and a locked-out box.
Resend with force=true to override.
"""
data = request.get_json() or {}
data = json_object()
firewall = data.get('firewall')
force = bool(data.get('force'))
@@ -58,7 +59,7 @@ def enable_firewall():
@admin_required
def disable_firewall():
"""Disable the firewall."""
data = request.get_json() or {}
data = json_object()
firewall = data.get('firewall')
result = FirewallService.disable(firewall)
@@ -84,7 +85,7 @@ def get_rules():
@admin_required
def add_rule():
"""Add a firewall rule."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'success': False, 'error': 'Request body required'}), 400
@@ -107,7 +108,7 @@ def add_rule():
@viewer_required
def rule_removal_preflight():
"""Would removing this rule cut off SSH? Lets the UI warn before the click."""
data = request.get_json() or {}
data = json_object()
rule_type = data.get('type')
kwargs = {k: v for k, v in data.items() if k not in ('type', 'force')}
return jsonify(FirewallService.check_ssh_rule_removal(rule_type, **kwargs)), 200
@@ -122,7 +123,7 @@ def remove_rule():
that is currently enforcing — deleting it would close the caller's own
session. Resend with force=true to override.
"""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'success': False, 'error': 'Request body required'}), 400
@@ -147,7 +148,7 @@ def remove_rule():
@admin_required
def block_ip():
"""Quick block an IP address."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'success': False, 'error': 'Request body required'}), 400
@@ -177,7 +178,7 @@ def block_ip():
@admin_required
def unblock_ip():
"""Unblock an IP address."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'success': False, 'error': 'Request body required'}), 400
@@ -210,7 +211,7 @@ def get_blocked_ips():
@admin_required
def allow_port():
"""Allow a port through the firewall."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'success': False, 'error': 'Request body required'}), 400
@@ -233,7 +234,7 @@ def allow_port():
@admin_required
def deny_port():
"""Remove a port from the firewall."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'success': False, 'error': 'Request body required'}), 400
@@ -272,7 +273,7 @@ def get_zones():
@admin_required
def set_default_zone():
"""Set default firewalld zone."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'success': False, 'error': 'Request body required'}), 400
@@ -301,7 +302,7 @@ def get_metadata_guard():
@admin_required
def set_metadata_guard():
"""Enable or disable the cloud metadata egress guard."""
data = request.get_json()
data = json_object()
if not data or 'enabled' not in data:
return jsonify({'error': "Request body with 'enabled' required"}), 400
@@ -323,7 +324,7 @@ def set_metadata_guard():
@admin_required
def install_firewall():
"""Install a firewall (ufw or firewalld)."""
data = request.get_json() or {}
data = json_object()
firewall = data.get('firewall', 'ufw')
result = FirewallService.install_firewall(firewall)
+15 -24
View File
@@ -1,5 +1,5 @@
"""REST surface for the fleet health doctor — the panel-host doctor extended
across the agent fleet (plan 26 / ``docs/FLEET_CONTRACT.md``).
across the agent fleet (``docs/FLEET_CONTRACT.md``).
Mounted at /api/v1/doctor/fleet (registered in app/__init__.py). Admin-only:
the fleet is not workspace-scoped (Fleet Contract, "explicitly out of scope").
@@ -19,12 +19,12 @@ owns the allowlist, the capability gate and the audit trail (rule 6) — this
layer only authenticates, shapes the batch, and reports refusals as results
rather than errors.
"""
from flask import Blueprint, jsonify, request
from app.error_reporting import unexpected_response
from flask import Blueprint, jsonify
from ..middleware.rbac import admin_required, get_current_user
from ..services.fleet_doctor_service import FLEET_DOCTOR_JOB_KIND, FleetDoctorService
from ..services.fleet_repair_service import FleetRepairService
from app.api._request import json_object
fleet_doctor_bp = Blueprint('fleet_doctor', __name__)
@@ -43,22 +43,16 @@ def get_fleet_report():
(or manually enqueued) job wrote. Servers with no rows yet are still listed
so a freshly paired box is visible before its first sweep.
"""
try:
return jsonify({'report': FleetDoctorService.fleet_report()})
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)
return jsonify({'report': FleetDoctorService.fleet_report()})
@fleet_doctor_bp.route('/run', methods=['POST'])
@admin_required
def run_fleet_sweep():
"""Enqueue the fleet sweep. 202 + job id — never synchronous."""
try:
from app.jobs.service import JobService
job = JobService.enqueue(FLEET_DOCTOR_JOB_KIND, payload={}, max_attempts=1)
return jsonify({'job_id': job.id}), 202
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)
from app.jobs.service import JobService
job = JobService.enqueue(FLEET_DOCTOR_JOB_KIND, payload={}, max_attempts=1)
return jsonify({'job_id': job.id}), 202
@fleet_doctor_bp.route('/repair', methods=['POST'])
@@ -71,7 +65,7 @@ def repair_fleet_items():
as a per-item ``success: false`` result — the request still succeeds, which
is what keeps one offline server from failing the whole batch.
"""
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
items = data.get('items')
if not isinstance(items, list) or not items:
return jsonify({'error': "Body must carry a non-empty 'items' list."}), 400
@@ -81,13 +75,10 @@ def repair_fleet_items():
user = get_current_user()
user_id = user.id if user else None
results = []
try:
for item in items:
if not isinstance(item, dict):
results.append({'success': False, 'error': 'Each item must be an object',
'code': 'BAD_REQUEST'})
continue
results.append(FleetRepairService.repair(item, user_id=user_id))
return jsonify({'results': results})
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)
for item in items:
if not isinstance(item, dict):
results.append({'success': False, 'error': 'Each item must be an object',
'code': 'BAD_REQUEST'})
continue
results.append(FleetRepairService.repair(item, user_id=user_id))
return jsonify({'results': results})
+3 -2
View File
@@ -11,6 +11,7 @@ from flask_jwt_extended import jwt_required
from app.services.fleet_monitor_service import fleet_monitor_service
from app.middleware.rbac import admin_required, get_current_user
from app.api._request import json_object
fleet_monitor_bp = Blueprint('fleet_monitor', __name__)
@@ -94,7 +95,7 @@ def get_thresholds():
@admin_required
def create_threshold():
"""Create or update an alert threshold."""
data = request.get_json()
data = json_object()
result = fleet_monitor_service.upsert_threshold(data)
if 'error' in result:
return jsonify(result), 400
@@ -190,7 +191,7 @@ def prometheus_metrics():
"""Prometheus-compatible metrics endpoint (no JWT, uses token param).
Accepts ``PROMETHEUS_TOKEN`` from the environment or the panel's own scrape
token, the one the Prometheus template is installed with (plan 86 §A4).
token, the one the Prometheus template is installed with.
"""
from app.services import metrics_token_service
token = request.args.get('token') or request.headers.get('X-Prometheus-Token')
+9 -8
View File
@@ -1,11 +1,11 @@
# Bucket: PER-APP (plan 29 #9, #11). The per-app deployment read gates on app
# Bucket: PER-APP. The per-app deployment read gates on app
# visibility (require_app_member); webhook reads are scoped to the linked app's
# visibility (an unlinked webhook stays panel-wide viewer-visible). Webhook
# mutations and the Gitea host lifecycle stay admin-only.
"""Git webhook + deployment API — the deploy-pipeline half.
The Gitea self-host surface (lifecycle + repo browsing) moved to the
serverkit-git extension (plan 52 Phase 6), which mounts it back at this same
serverkit-git extension, which mounts it back at this same
/api/v1/git prefix when installed."""
from flask import Blueprint, request, jsonify
@@ -18,6 +18,7 @@ from ..models import Application, GitWebhook
from ..services.webhook_service import WebhookService
from ..services.git_deploy_service import GitDeployService
from app.exceptions import not_found, permission_denied
from app.api._request import json_object
git_bp = Blueprint('git', __name__)
@@ -25,7 +26,7 @@ git_bp = Blueprint('git', __name__)
def _webhook_visible_to(user, webhook):
"""A webhook with no app linkage is a panel-wide viewer-visible resource; an
app-linked webhook is visible only to callers who can reach that app
(owner/admin/grant/workspace member — plan 29 #11)."""
(owner/admin/grant/workspace member)."""
if not getattr(webhook, 'app_id', None):
return True
# Deliberately unfiltered: this gate falls through to "panel-wide visible"
@@ -44,7 +45,7 @@ def _webhook_visible_to(user, webhook):
@git_bp.route('/webhooks', methods=['GET'])
@viewer_required
def list_webhooks():
"""List webhooks the caller can see (plan 29 #11): every unlinked webhook
"""List webhooks the caller can see: every unlinked webhook
plus any app-linked webhook whose app they can reach."""
user = get_current_user()
result = WebhookService.list_webhooks()
@@ -64,7 +65,7 @@ def list_webhooks():
@admin_required
def create_webhook():
"""Create a new webhook."""
data = request.get_json() or {}
data = json_object()
result = WebhookService.create_webhook(
name=data.get('name'),
@@ -105,7 +106,7 @@ def get_webhook(webhook_id):
@admin_required
def update_webhook(webhook_id):
"""Update a webhook."""
data = request.get_json() or {}
data = json_object()
result = WebhookService.update_webhook(webhook_id, data)
@@ -236,7 +237,7 @@ def get_deployment(deployment_id):
@admin_required
def manual_deploy(app_id):
"""Trigger a manual deployment for an application."""
data = request.get_json() or {}
data = json_object()
branch = data.get('branch')
result = GitDeployService.manual_deploy(app_id, branch=branch)
@@ -250,7 +251,7 @@ def manual_deploy(app_id):
@admin_required
def rollback_deployment(app_id):
"""Rollback to a previous deployment version."""
data = request.get_json() or {}
data = json_object()
target_version = data.get('targetVersion')
result = GitDeployService.rollback(app_id, target_version=target_version)
+3 -2
View File
@@ -7,10 +7,11 @@ Kept out of apps.py on purpose (separate ownership); intended registration:
which exposes POST /api/v1/apps/htaccess-convert.
"""
from flask import Blueprint, jsonify, request
from flask import Blueprint, jsonify
from flask_jwt_extended import jwt_required
from app.services.htaccess_converter import MAX_INPUT_BYTES, convert
from app.api._request import json_object
htaccess_tools_bp = Blueprint('htaccess_tools', __name__)
@@ -23,7 +24,7 @@ def htaccess_convert():
Pure text transform (no server state touched), so any authenticated
user may call it. Body: {'htaccess': '<text>'}.
"""
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
text = data.get('htaccess')
if not isinstance(text, str) or not text.strip():
return jsonify({'error': 'htaccess text is required'}), 400
+2 -2
View File
@@ -1,4 +1,4 @@
# Bucket: PER-APP (plan 29 #9). The registry-check trigger stays admin; the
# Bucket: PER-APP. The registry-check trigger stays admin; the
# latest-check read is scoped to callers who can access the app (can_access_app).
from flask import Blueprint, jsonify
from flask_jwt_extended import jwt_required
@@ -29,7 +29,7 @@ def check(app_id):
def latest(app_id):
"""Return the most recent image-update check for the application (or null).
Scoped to the app's workspace visibility (plan 29 #9) — a foreign caller
Scoped to the app's workspace visibility — a foreign caller
gets a sealed 404."""
app = Application.query_active().filter_by(id=app_id).first()
user = get_current_user()
+2 -1
View File
@@ -5,6 +5,7 @@ from app.models import AuditLog
from app.middleware.rbac import admin_required
from app.services.invitation_service import InvitationService
from app.services.audit_service import AuditService
from app.api._request import json_object
invitations_bp = Blueprint('invitations', __name__)
@@ -24,7 +25,7 @@ def list_invitations():
@admin_required
def create_invitation():
"""Create a new invitation."""
data = request.get_json()
data = json_object()
current_user_id = get_jwt_identity()
if not data:
+2 -1
View File
@@ -7,6 +7,7 @@ from app.jobs import registry
from app.jobs.models import Job
from app.jobs.service import JobService, ScheduledJobService
from app.middleware.rbac import require_admin_user
from app.api._request import json_object
jobs_bp = Blueprint('jobs', __name__)
@@ -87,7 +88,7 @@ def run_scheduled(scheduled_id):
@jwt_required()
def toggle_scheduled(scheduled_id):
require_admin_user()
body = request.get_json(silent=True) or {}
body = json_object(optional=True)
scheduled = ScheduledJobService.set_enabled(scheduled_id, bool(body.get('enabled', True)))
if not scheduled:
return jsonify({'error': 'Scheduled job not found'}), 404
+3 -2
View File
@@ -1,9 +1,10 @@
"""Linked-panel API — manage this panel's link to a master ServerKit panel."""
from flask import Blueprint, jsonify, request
from flask import Blueprint, jsonify
from flask_jwt_extended import jwt_required
from app.middleware.rbac import developer_required
from app.services.linked_panel_service import LinkedPanelService
from app.api._request import json_object
linked_panel_bp = Blueprint('linked_panel', __name__)
@@ -25,7 +26,7 @@ def link():
The registration token comes from the master's Servers page (Add Server
/ regenerate-token) — the same token the Go agent consumes.
"""
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
result = LinkedPanelService.link(
master_url=data.get('master_url'),
registration_token=data.get('registration_token'),
+9 -2
View File
@@ -1,6 +1,8 @@
from flask import Blueprint, request, jsonify
from app.middleware.rbac import admin_required
from app.services.log_service import LogService
from app.api._request import json_object
from app.exceptions import ValidationError
logs_bp = Blueprint('logs', __name__)
@@ -61,7 +63,12 @@ def get_journal_logs():
unit = request.args.get('unit')
lines = request.args.get('lines', 100, type=int)
since = request.args.get('since')
priority = request.args.get('priority')
priority = request.args.get('priority') or None
# journalctl levels 0 (emerg) .. 7 (debug); anything else is a client error,
# not something to hand to a privileged journalctl.
if priority is not None and priority not in {str(n) for n in range(8)}:
raise ValidationError('priority must be a journal level from 0 to 7',
code='validation.priority')
result = LogService.get_journalctl_logs(unit, lines, since, priority)
return jsonify(result), 200 if result['success'] else 400
@@ -71,7 +78,7 @@ def get_journal_logs():
@admin_required
def clear_log():
"""Clear/truncate a log file."""
data = request.get_json()
data = json_object()
filepath = data.get('path') if data else None
if not filepath:
+2 -1
View File
@@ -14,6 +14,7 @@ from app.middleware.rbac import admin_required, developer_required
from app.services.managed_database_service import ManagedDatabaseService
from app.services.managed_db_user_service import ManagedDbUserService
from app.exceptions import not_found
from app.api._request import json_object
logger = logging.getLogger(__name__)
@@ -46,7 +47,7 @@ def create_managed_db_user(managed_id):
managed, err = _get_managed_or_404(managed_id)
if err:
return err
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
result = ManagedDbUserService.create_user(
managed,
username=data.get('username'),
+6 -5
View File
@@ -18,6 +18,7 @@ from app.services.manifest_apply_service import ManifestApplyService
from app.services.manifest_persistence_service import ManifestPersistenceService
from app.middleware.rbac import require_admin_user
from app.exceptions import not_found
from app.api._request import json_object
manifests_bp = Blueprint('manifests', __name__)
@@ -73,7 +74,7 @@ def validate_manifest():
"""POST /api/v1/manifests/validate { content | manifest } -> normalized summary."""
user = require_admin_user()
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
try:
if 'content' in data:
normalized = ManifestSpecService.normalize_text(data['content'])
@@ -110,7 +111,7 @@ def get_manifest():
def plan_manifest():
"""POST /api/v1/manifests/plan {project_id, content|manifest?} -> dry-run plan."""
user = require_admin_user()
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
project = Project.query.get(data.get('project_id')) if data.get('project_id') else None
if not project:
return jsonify({'error': 'A valid project_id is required'}), 400
@@ -128,7 +129,7 @@ def plan_manifest():
def apply_manifest():
"""POST /api/v1/manifests/apply {project_id, content|manifest?} -> apply."""
user = require_admin_user()
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
project = Project.query.get(data.get('project_id')) if data.get('project_id') else None
if not project:
return jsonify({'error': 'A valid project_id is required'}), 400
@@ -145,7 +146,7 @@ def apply_manifest():
result = ManifestApplyService.apply(project, normalized, user_id=user.id,
manifest_row=row)
# Appliance-tier blockers (plan 35): apply refused, nothing executed.
# Appliance-tier blockers: apply refused, nothing executed.
if result.get('refused'):
return jsonify(result), 400
@@ -172,7 +173,7 @@ def reset_bootstrap():
typing the app name in `confirm`.
"""
user = require_admin_user()
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
app = (Application.query_active().filter_by(id=data.get('app_id')).first()
if data.get('app_id') else None)
if not app:
+2 -1
View File
@@ -12,6 +12,7 @@ from app.services.audit_service import AuditService
from app.models.audit_log import AuditLog
from app.middleware.rbac import get_current_user
from app.error_reporting import unexpected_response
from app.api._request import json_object
marketplace_bp = Blueprint('marketplace', __name__)
@@ -55,7 +56,7 @@ def install_registry(slug):
if entry is not None and entry.get('trust') == 'unreviewed' \
and not registry_service._show_unreviewed():
return jsonify({'error': 'Extension not found in the registry'}), 404
body = request.get_json(silent=True) or {}
body = json_object(optional=True)
trust = (entry or {}).get('trust', 'unreviewed')
acknowledged = body.get('acknowledge_risk') is True
# Consent gate (shared with the update path — audits M2/M3):
+5 -4
View File
@@ -1,8 +1,9 @@
from flask import Blueprint, request, jsonify
from flask import Blueprint, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.models.user import User
from app import db
from app.exceptions import not_found
from app.api._request import json_object
mobile_bp = Blueprint('mobile', __name__)
@@ -12,7 +13,7 @@ mobile_bp = Blueprint('mobile', __name__)
def register_push():
"""Register a device for push notifications."""
user_id = get_jwt_identity()
data = request.get_json() or {}
data = json_object()
subscription = data.get('subscription')
device_name = data.get('device_name', 'Unknown')
@@ -45,7 +46,7 @@ def register_push():
@jwt_required()
def unregister_push():
"""Unregister a device from push notifications."""
data = request.get_json() or {}
data = json_object()
endpoint = data.get('endpoint')
if not endpoint:
return jsonify({'error': 'endpoint required'}), 400
@@ -69,7 +70,7 @@ def get_quick_actions():
@jwt_required()
def execute_quick_action(action_id):
"""Execute a quick action."""
data = request.get_json() or {}
data = json_object()
if action_id == 'view_stats':
# MonitoringService owns the live-metrics probe; ServerMetricsService
+3 -2
View File
@@ -3,13 +3,14 @@
Read is available to any authenticated user (the frontend needs it to hide nav
and guard routes); flipping a toggle is admin-only.
"""
from flask import Blueprint, request, jsonify
from flask import Blueprint, jsonify
from flask_jwt_extended import jwt_required
from app.services import module_service
from app.services.audit_service import AuditService
from app.models.audit_log import AuditLog
from app.middleware.rbac import get_current_user
from app.api._request import json_object
modules_bp = Blueprint('modules', __name__)
@@ -31,7 +32,7 @@ def set_module(name):
if name not in module_service.MODULES:
return jsonify({'error': f'Unknown module: {name}'}), 404
data = request.get_json() or {}
data = json_object()
if 'enabled' not in data:
return jsonify({'error': 'enabled (boolean) required'}), 400
+3 -2
View File
@@ -2,6 +2,7 @@ from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required
from app.middleware.rbac import admin_required
from app.services.monitoring_service import MonitoringService
from app.api._request import json_object
monitoring_bp = Blueprint('monitoring', __name__)
@@ -62,7 +63,7 @@ def get_config():
@admin_required
def update_config():
"""Update monitoring configuration."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -107,7 +108,7 @@ def get_thresholds():
@admin_required
def update_thresholds():
"""Update alert thresholds."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
+6 -5
View File
@@ -12,6 +12,7 @@ from flask_jwt_extended import jwt_required
from app.exceptions import NotFoundError
from app.middleware.rbac import permission_required
from app.services.monitor_service import MonitorService
from app.api._request import json_object
monitors_bp = Blueprint('monitors', __name__)
@@ -52,7 +53,7 @@ def get_stats():
@monitors_bp.route('/', methods=['POST'])
@permission_required('monitoring', 'write')
def create_monitor():
data = request.get_json() or {}
data = json_object()
monitor = MonitorService.create(data)
return jsonify(monitor.to_dict()), 201
@@ -69,7 +70,7 @@ def get_monitor(monitor_id):
@monitors_bp.route('/<int:monitor_id>', methods=['PATCH', 'PUT'])
@permission_required('monitoring', 'write')
def update_monitor(monitor_id):
data = request.get_json() or {}
data = json_object()
monitor = MonitorService.update(monitor_id, data)
if not monitor:
raise NotFoundError('Monitor not found', code='monitor_not_found')
@@ -98,7 +99,7 @@ def run_check(monitor_id):
@monitors_bp.route('/<int:monitor_id>/pause', methods=['POST'])
@permission_required('monitoring', 'write')
def set_paused(monitor_id):
data = request.get_json() or {}
data = json_object()
monitor = MonitorService.set_paused(monitor_id, data.get('paused', True))
if not monitor:
return {'error': 'Monitor not found'}, 404
@@ -151,7 +152,7 @@ def list_incidents():
@monitors_bp.route('/incidents', methods=['POST'])
@permission_required('monitoring', 'write')
def create_incident():
data = request.get_json() or {}
data = json_object()
if not data.get('title'):
return {'error': 'Incident title is required'}, 400
incident = MonitorService.create_incident(data.get('page_id'), data)
@@ -161,7 +162,7 @@ def create_incident():
@monitors_bp.route('/incidents/<int:incident_id>', methods=['PATCH', 'PUT'])
@permission_required('monitoring', 'write')
def update_incident(incident_id):
incident = MonitorService.update_incident(incident_id, request.get_json() or {})
incident = MonitorService.update_incident(incident_id, json_object())
if not incident:
return {'error': 'Incident not found'}, 404
return jsonify(incident.to_dict())
+4 -3
View File
@@ -1,6 +1,7 @@
from flask import Blueprint, request, jsonify
from flask import Blueprint, jsonify
from app.middleware.rbac import admin_required
from app.services.nginx_service import NginxService
from app.api._request import json_object
nginx_bp = Blueprint('nginx', __name__)
@@ -49,7 +50,7 @@ def list_sites():
@admin_required
def create_site():
"""Create a new Nginx site configuration."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -99,7 +100,7 @@ def delete_site(name):
@admin_required
def add_ssl_to_site(name):
"""Add SSL configuration to a site."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
+2 -1
View File
@@ -2,6 +2,7 @@ from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required
from app.services.nginx_advanced_service import NginxAdvancedService
from app.middleware.rbac import get_current_user
from app.api._request import json_object
nginx_advanced_bp = Blueprint('nginx_advanced', __name__)
@@ -43,7 +44,7 @@ def preview_diff():
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json() or {}
data = json_object()
domain = data.get('domain')
new_config = data.get('config', '')
if not domain:
+9 -12
View File
@@ -12,7 +12,7 @@ from app.services.settings_service import SettingsService
from app.services.bounce_service import BounceService
from app.notifications.service import NotificationBusService
from app.notifications.providers import EmailProviderService, SUPPORTED as EMAIL_PROVIDERS
from app.error_reporting import unexpected_response
from app.api._request import json_object
# Header carrying the provider's HMAC-SHA256 of the raw request body, formatted
# as ``sha256=<hexdigest>`` (GitHub-style). The shared secret is
@@ -64,7 +64,7 @@ def get_config():
@admin_required
def update_channel_config(channel):
"""Update configuration for a specific notification channel."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -179,7 +179,7 @@ def get_user_preferences():
def update_user_preferences():
"""Update current user's notification preferences."""
current_user_id = get_jwt_identity()
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -225,11 +225,8 @@ def update_user_preferences():
if 'end' in qh:
prefs.quiet_hours_end = qh['end']
try:
db.session.commit()
return jsonify({'success': True, 'preferences': prefs.to_dict()}), 200
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc) # rolls the session back itself
db.session.commit()
return jsonify({'success': True, 'preferences': prefs.to_dict()}), 200
@notifications_bp.route('/preferences/test', methods=['POST'])
@@ -406,7 +403,7 @@ def list_chat_connections():
def add_chat_connection():
"""Create a chat/webhook connection (destination + secret encrypted)."""
from app.services.chat_webhook_service import ChatWebhookService
data = request.get_json() or {}
data = json_object()
data['created_by'] = int(get_jwt_identity())
try:
conn = ChatWebhookService.add(data)
@@ -486,7 +483,7 @@ def list_email_providers():
@admin_required
def add_email_provider():
"""Add an email provider, then validate its credentials."""
data = request.get_json() or {}
data = json_object()
try:
provider = EmailProviderService.add_provider(data, user_id=int(get_jwt_identity()))
except ValueError as exc:
@@ -507,8 +504,8 @@ def test_email_provider(provider_id):
@admin_required
def update_email_provider(provider_id):
"""Update a provider's usage flags (uses_notifications / uses_relay /
relay_priority) — §6 unification. uses_relay applies only to SMTP."""
row = EmailProviderService.update_usage(provider_id, request.get_json() or {})
relay_priority). uses_relay applies only to SMTP."""
row = EmailProviderService.update_usage(provider_id, json_object())
if row is None:
return jsonify({'error': 'Provider not found'}), 404
return jsonify({'success': True, 'provider': row.to_dict()}), 200
+9 -8
View File
@@ -25,6 +25,7 @@ from app.services.pairing_service import (
AlreadyClaimedError,
ExpiredEnrollmentError,
)
from app.api._request import json_object
pairing_bp = Blueprint('pairing', __name__)
@@ -34,7 +35,7 @@ def _enrollment_creds():
enrollment_id = request.headers.get('X-Enrollment-Id')
enrollment_secret = request.headers.get('X-Enrollment-Secret')
if not enrollment_id and request.is_json:
body = request.get_json(silent=True) or {}
body = json_object(optional=True)
enrollment_id = body.get('enrollment_id')
enrollment_secret = body.get('enrollment_secret')
return enrollment_id, enrollment_secret
@@ -50,7 +51,7 @@ def _error_response(exc: PairingError):
@pairing_bp.route('/enroll', methods=['POST'])
@limiter.limit("10 per minute")
def enroll():
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
pubkey = data.get('pubkey')
passphrase = data.get('passphrase')
machine_id = data.get('machine_id')
@@ -79,7 +80,7 @@ def enroll():
@limiter.limit("30 per minute")
def code_refresh():
enrollment_id, enrollment_secret = _enrollment_creds()
body = request.get_json(silent=True) or {}
body = json_object(optional=True)
force = bool(body.get('force', False))
try:
result = pairing_service.rotate_code(enrollment_id, enrollment_secret, force=force)
@@ -92,7 +93,7 @@ def code_refresh():
@limiter.limit("30 per minute")
def code_freeze():
enrollment_id, enrollment_secret = _enrollment_creds()
body = request.get_json(silent=True) or {}
body = json_object(optional=True)
frozen = bool(body.get('frozen', True))
try:
result = pairing_service.set_freeze(enrollment_id, enrollment_secret, frozen=frozen)
@@ -120,7 +121,7 @@ def poll():
# given enrollment, so read it once before the long-poll loop.
signature = request.headers.get('X-Enrollment-Signature')
if not signature and request.is_json:
signature = (request.get_json(silent=True) or {}).get('signature')
signature = json_object(optional=True).get('signature')
deadline = time.time() + 25.0 # leave headroom under typical proxy timeouts
poll_interval = 1.0
@@ -149,7 +150,7 @@ def poll():
@limiter.limit("20 per minute")
def lookup():
"""Pre-flight: confirm a code exists (without revealing details)."""
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
code = data.get('code', '')
pending = pairing_service.find_by_code(code)
if not pending:
@@ -167,7 +168,7 @@ def lookup():
@limiter.limit("5 per 10 minute")
def claim():
user_id = int(get_jwt_identity())
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
code = data.get('code')
passphrase = data.get('passphrase')
name = data.get('name')
@@ -184,7 +185,7 @@ def claim():
name=name,
group_id=group_id,
# Recorded for the pairing audit trail — the real operator IP once
# TRUST_PROXY_HEADERS is on (ProxyFix-corrected), not nginx's (plan 48).
# TRUST_PROXY_HEADERS is on (ProxyFix-corrected), not nginx's.
ip_address=request.remote_addr,
)
except PairingError as exc:
+5 -4
View File
@@ -1,6 +1,7 @@
from flask import Blueprint, request, jsonify
from flask import Blueprint, jsonify
from app.middleware.rbac import admin_required
from app.services.php_service import PHPService
from app.api._request import json_object
php_bp = Blueprint('php', __name__)
@@ -30,7 +31,7 @@ def install_version(version):
@admin_required
def set_default_version():
"""Set default PHP version."""
data = request.get_json()
data = json_object()
version = data.get('version') if data else None
if not version:
@@ -52,7 +53,7 @@ def get_extensions(version):
@admin_required
def install_extension(version):
"""Install a PHP extension."""
data = request.get_json()
data = json_object()
extension = data.get('extension') if data else None
if not extension:
@@ -74,7 +75,7 @@ def get_pools(version):
@admin_required
def create_pool(version):
"""Create a new FPM pool."""
data = request.get_json()
data = json_object()
if not data or 'name' not in data:
return jsonify({'error': 'Pool name is required'}), 400
+10 -9
View File
@@ -12,6 +12,7 @@ from app.services.audit_service import AuditService
from app.models.audit_log import AuditLog
from app.middleware.rbac import get_current_user
from app.error_reporting import unexpected_response
from app.api._request import json_object
plugins_bp = Blueprint('plugins', __name__)
@@ -54,7 +55,7 @@ def install_plugin():
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json()
data = json_object()
if not data or 'url' not in data:
return jsonify({'error': 'url required'}), 400
@@ -65,7 +66,7 @@ def install_plugin():
# Optional checksum continuity: the preview step returns the resolved URL +
# sha256; passing them here pins the install to the exact previewed bytes.
expected_sha256 = (data.get('sha256') or None)
# Optional signature continuity (plan 55): when the preview verified a
# Optional signature continuity: when the preview verified a
# detached signature, pass it through so the install re-verifies the exact
# pinned bytes. A bad signature is a hard failure; no signature = the
# consent card's "unsigned" path.
@@ -119,7 +120,7 @@ def preview_plugin():
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json() or {}
data = json_object()
url = (data.get('url') or '').strip()
if not url:
return jsonify({'error': 'url required'}), 400
@@ -152,7 +153,7 @@ def install_plugin_local():
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json() or {}
data = json_object()
path = (data.get('path') or '').strip()
if not path:
return jsonify({'error': 'path required'}), 400
@@ -247,7 +248,7 @@ def uninstall_plugin(plugin_id):
# ?purge=true or a JSON body {"purge": true}.
purge = str(request.args.get('purge', '')).lower() in ('1', 'true', 'yes')
if not purge:
body = request.get_json(silent=True) or {}
body = json_object(optional=True)
purge = bool(body.get('purge'))
plugin_name = plugin.name
@@ -337,7 +338,7 @@ def recommendations():
use_cases = [u.strip() for u in raw.split(',') if u.strip()]
return jsonify({
'recommendations': recommend_extensions_for_use_cases(use_cases),
# Wizard round 2 (plan 47 Ph5): the security-posture chooser. Each
# Wizard round 2: the security-posture chooser. Each
# level lists only extensions the catalog can actually install today —
# unpublished slugs drop out rather than rendering dead checkboxes.
'security_postures': {
@@ -518,7 +519,7 @@ def get_manifest_spec():
@plugins_bp.route('/<int:plugin_id>/permissions', methods=['GET'])
@jwt_required()
def get_plugin_permissions(plugin_id):
"""Declared vs actually-observed permission use for one extension (plan 55).
"""Declared vs actually-observed permission use for one extension.
Admin-only: it describes what a piece of third-party code has been reaching
for, which is operator information.
@@ -614,7 +615,7 @@ def update_plugin_config(plugin_id):
if not plugin:
return jsonify({'error': 'Plugin not found'}), 404
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
config = data.get('config')
if not isinstance(config, dict):
return jsonify({'error': 'config must be an object'}), 400
@@ -681,7 +682,7 @@ def update_plugin_route(plugin_id):
and not registry_service._show_unreviewed():
return jsonify({'error': 'Extension not found in the registry'}), 404
body = request.get_json(silent=True) or {}
body = json_object(optional=True)
acknowledged = body.get('acknowledge_risk') is True
reason, message = registry_service.consent_gate(entry, acknowledged)
if reason:
+12 -46
View File
@@ -1,4 +1,4 @@
# Bucket: PER-APP (plan 29 #9). Reads require app access (can_access_app); the
# Bucket: PER-APP. Reads require app access (can_access_app); the
# enable/sync/redeploy/destroy mutations require write access (can_edit_app),
# replacing the old panel-wide @developer_required.
"""PR Preview Environments API.
@@ -6,42 +6,22 @@
Mounted by the host at ``/api/v1/apps`` so every route here is nested under an
application id, e.g. ``GET /api/v1/apps/<app_id>/previews``.
"""
from flask import Blueprint, request, jsonify
from flask import Blueprint, jsonify
from flask_jwt_extended import jwt_required
from app.models import Application
from app.models.application_preview import ApplicationPreview
from app.services.preview_service import PreviewService
from app.services.resource_grant_service import ResourceGrantService
from app.middleware.rbac import get_current_user
from app.error_reporting import unexpected_response
from app.exceptions import not_found
from app.api._access import require_app
from app.api._request import json_object
previews_bp = Blueprint('previews', __name__)
def _get_app_or_404(app_id, write=False):
"""Resolve the app and enforce per-app access (plan 29 #9). ``write`` requires
can_edit_app (owner/admin/editor); otherwise can_access_app (adds workspace
members). A caller without the needed tier gets a sealed 404 (no leak)."""
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
user = get_current_user()
ok = ResourceGrantService.can_edit_app(user, app) if write \
else ResourceGrantService.can_access_app(user, app)
if not ok:
raise not_found('service')
return app, None
@previews_bp.route('/<int:app_id>/previews', methods=['GET'])
@jwt_required()
def list_previews(app_id):
"""List the non-destroyed previews for an application (newest first)."""
app, err = _get_app_or_404(app_id)
if err:
return err
app = require_app(app_id, conceal=True)
previews = ApplicationPreview.query.filter(
ApplicationPreview.application_id == app_id,
ApplicationPreview.status != ApplicationPreview.STATUS_DESTROYED,
@@ -53,9 +33,7 @@ def list_previews(app_id):
@jwt_required()
def get_preview_settings(app_id):
"""Return the per-app preview settings (defaults when never configured)."""
app, err = _get_app_or_404(app_id)
if err:
return err
app = require_app(app_id, conceal=True)
settings = PreviewService.get_settings(app_id)
return jsonify(settings.to_dict()), 200
@@ -64,15 +42,9 @@ def get_preview_settings(app_id):
@jwt_required()
def update_preview_settings(app_id):
"""Enable/disable previews and set the domain template + TTL for an app."""
app, err = _get_app_or_404(app_id, write=True)
if err:
return err
data = request.get_json(silent=True) or {}
try:
result = PreviewService.enable_previews(app_id, data)
except Exception as exc: # pragma: no cover - defensive
# Only a failed commit lands here: a crash, not the caller's mistake.
return unexpected_response(exc)
app = require_app(app_id, write=True, conceal=True)
data = json_object(optional=True)
result = PreviewService.enable_previews(app_id, data)
return jsonify(result), 200
@@ -80,9 +52,7 @@ def update_preview_settings(app_id):
@jwt_required()
def sync_previews(app_id):
"""Reconcile this app's previews against its open PRs (best-effort)."""
app, err = _get_app_or_404(app_id, write=True)
if err:
return err
app = require_app(app_id, write=True, conceal=True)
result = PreviewService.sync_previews(app_id)
status = 400 if result.get('error') else 200
return jsonify(result), status
@@ -92,9 +62,7 @@ def sync_previews(app_id):
@jwt_required()
def redeploy_preview(app_id, preview_id):
"""Re-provision a preview for the same PR (idempotent update-in-place)."""
app, err = _get_app_or_404(app_id, write=True)
if err:
return err
app = require_app(app_id, write=True, conceal=True)
preview = ApplicationPreview.query.filter_by(
id=preview_id, application_id=app_id).first()
if not preview:
@@ -113,9 +81,7 @@ def redeploy_preview(app_id, preview_id):
@jwt_required()
def destroy_preview(app_id, preview_id):
"""Tear down and mark a preview destroyed."""
app, err = _get_app_or_404(app_id, write=True)
if err:
return err
app = require_app(app_id, write=True, conceal=True)
preview = ApplicationPreview.query.filter_by(
id=preview_id, application_id=app_id).first()
if not preview:
+5 -4
View File
@@ -1,8 +1,8 @@
# Bucket: PER-APP (plan 29 #9). Private-URL routes gate on the shared app-access
# Bucket: PER-APP. Private-URL routes gate on the shared app-access
# seam (can_access_app for reads, can_edit_app for mutations).
"""API endpoints for managing private URLs for applications."""
from flask import Blueprint, request, jsonify
from flask import Blueprint, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from app import db
from app.models import Application, User
@@ -10,6 +10,7 @@ from app.services.private_url_service import PrivateURLService
from app.services.nginx_service import NginxService
from app.services.resource_grant_service import ResourceGrantService
from app.exceptions import not_found, permission_denied
from app.api._request import json_object
private_urls_bp = Blueprint('private_urls', __name__)
@@ -45,7 +46,7 @@ def enable_private_url(app_id):
'private_url': f'/p/{app.private_slug}'
}), 400
data = request.get_json() or {}
data = json_object()
custom_slug = data.get('slug')
if custom_slug:
@@ -132,7 +133,7 @@ def update_private_url(app_id):
if not app.private_url_enabled:
return jsonify({'error': 'Private URL is not enabled for this service'}), 400
data = request.get_json()
data = json_object()
if not data or 'slug' not in data:
return jsonify({'error': 'Slug is required'}), 400
+2 -1
View File
@@ -1,6 +1,7 @@
from flask import Blueprint, request, jsonify
from app.middleware.rbac import admin_required
from app.services.process_service import ProcessService
from app.api._request import json_object
processes_bp = Blueprint('processes', __name__)
@@ -47,7 +48,7 @@ def get_services():
@admin_required
def control_service(service_name):
"""Control a system service."""
data = request.get_json()
data = json_object()
action = data.get('action') if data else None
if not action:
+6 -18
View File
@@ -14,6 +14,8 @@ from app.services.project_service import ProjectService
from app.services.workspace_service import WorkspaceService
from app.middleware.rbac import get_current_user
from app.exceptions import permission_denied
from app.api._access import visible_workspace_ids
from app.api._request import json_object
projects_bp = Blueprint('projects', __name__)
@@ -28,24 +30,10 @@ def _active_workspace_id(user):
return ws_id
def _accessible_workspace_ids(user):
"""Workspace ids the caller may see projects for. With an explicit, valid
workspace context, just that one; otherwise all the user's workspaces (admins
see all)."""
ws_id = WorkspaceService.resolve_workspace_id(
user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id'))
if ws_id is not None:
return [ws_id]
if user.is_admin:
from app.models.workspace import Workspace
return [w.id for w in Workspace.query.all()]
return [w.id for w in WorkspaceService.list_workspaces(user_id=user.id)]
def _can_view_project(user, project):
if user.is_admin:
return True
return project.workspace_id in set(_accessible_workspace_ids(user))
return project.workspace_id in set(visible_workspace_ids(user))
@projects_bp.route('', methods=['GET'])
@@ -54,7 +42,7 @@ def _can_view_project(user, project):
def list_projects():
"""List projects in the accessible workspace(s), each with resource counts."""
user = get_current_user()
ws_ids = _accessible_workspace_ids(user)
ws_ids = visible_workspace_ids(user)
projects = []
for ws_id in ws_ids:
projects.extend(ProjectService.list_projects(ws_id))
@@ -67,7 +55,7 @@ def list_projects():
def create_project():
"""Create a project (auto-creates a default environment)."""
user = get_current_user()
data = request.get_json() or {}
data = json_object()
name = (data.get('name') or '').strip()
if not name:
return jsonify({'error': 'Project name is required'}), 400
@@ -122,7 +110,7 @@ def update_project(project_id):
if not WorkspaceService.can_write_in_workspace(user, project.workspace_id):
return jsonify({'error': 'You have read-only access to this workspace'}), 403
data = request.get_json() or {}
data = json_object()
updated = ProjectService.update_project(
project_id,
name=data.get('name'),
+4 -3
View File
@@ -16,6 +16,7 @@ from flask_jwt_extended import jwt_required
from app.models.server import Server
from app.middleware.rbac import developer_required
from app.services.proxy_stack_service import ProxyStackService
from app.api._request import json_object
proxy_bp = Blueprint('proxy', __name__)
@@ -114,7 +115,7 @@ def configure_proxy(server_id):
if err:
return err
data = request.get_json() or {}
data = json_object()
proxy_type = data.get('proxy_type')
custom_snippet = data.get('custom_snippet')
@@ -142,7 +143,7 @@ def regenerate_proxy(server_id):
if err:
return err
data = request.get_json() or {}
data = json_object()
sites = data.get('sites')
result = ProxyStackService.regenerate(server_id, sites=sites)
status = 200 if result.get('success') else 502
@@ -157,7 +158,7 @@ def switch_proxy(server_id):
if err:
return err
data = request.get_json() or {}
data = json_object()
proxy_type = data.get('proxy_type')
if not proxy_type:
return jsonify({'error': 'proxy_type is required'}), 400
+10 -9
View File
@@ -1,13 +1,14 @@
# Bucket: PER-APP (plan 29 #9). Per-app Python runtime routes gate on the shared
# Bucket: PER-APP. Per-app Python runtime routes gate on the shared
# app-access seam (can_access_app for reads, can_edit_app for mutations);
# host-level interpreter management stays admin-only.
from flask import Blueprint, request, jsonify
from flask import Blueprint, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.middleware.rbac import admin_required
from app.models import User, Application
from app.services.python_service import PythonService
from app import db
from app.exceptions import not_found, permission_denied
from app.api._request import json_object
python_bp = Blueprint('python', __name__)
@@ -51,7 +52,7 @@ def get_versions():
@admin_required
def create_flask_app():
"""Create a new Flask application."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -110,7 +111,7 @@ def create_flask_app():
@admin_required
def create_django_app():
"""Create a new Django application."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -202,7 +203,7 @@ def install_packages(app_id):
if error:
return jsonify(error[0]), error[1]
data = request.get_json()
data = json_object()
packages = data.get('packages') if data else None
result = PythonService.install_requirements(app.root_path, packages)
@@ -255,7 +256,7 @@ def set_env_vars(app_id):
if error:
return jsonify(error[0]), error[1]
data = request.get_json()
data = json_object()
if not data or 'env_vars' not in data:
return jsonify({'error': 'env_vars is required'}), 400
@@ -364,7 +365,7 @@ def update_gunicorn_config(app_id):
if error:
return jsonify(error[0]), error[1]
data = request.get_json()
data = json_object()
if not data or 'content' not in data:
return jsonify({'error': 'content is required'}), 400
@@ -412,7 +413,7 @@ def run_command(app_id):
if error:
return jsonify(error[0]), error[1]
data = request.get_json()
data = json_object()
if not data or 'command' not in data:
return jsonify({'error': 'command is required'}), 400
@@ -436,7 +437,7 @@ def delete_app(app_id):
if error:
return jsonify(error[0]), error[1]
data = request.get_json() or {}
data = json_object()
remove_files = data.get('remove_files', False)
result = PythonService.delete_app(app.name, app.root_path, remove_files)
+166 -242
View File
@@ -3,8 +3,8 @@ from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required
from app.queue_bus.service import QueueBusService, QueueBusError
from app.error_reporting import unexpected_response
from app.middleware.rbac import get_current_user
from app.api._request import json_object
queue_bus_bp = Blueprint('queue_bus', __name__)
@@ -47,19 +47,6 @@ def _ensure_group_accessible(group_slug):
raise QueueBusError('Access denied', 403)
def _handle_error(e):
"""Map a caught exception to a response.
A QueueBusError is an expected, caller-actionable failure and keeps its own
message and status. Anything else is a crash: report it so it reaches
/monitoring/errors, and answer the standard body instead of handing the
caller str(e).
"""
if isinstance(e, QueueBusError):
return jsonify({'error': e.message}), e.status_code
return unexpected_response(e)
# ----------------------------------------------------------------------
# Groups
# ----------------------------------------------------------------------
@@ -67,96 +54,81 @@ def _handle_error(e):
@queue_bus_bp.route('/groups', methods=['GET'])
@jwt_required()
def list_groups():
try:
owner_type = request.args.get('owner_type')
owner_id = request.args.get('owner_id')
limit = request.args.get('limit', 100, type=int)
offset = request.args.get('offset', 0, type=int)
groups = QueueBusService.list_groups(
owner_type=owner_type,
owner_id=owner_id,
limit=limit,
offset=offset,
)
return jsonify({'groups': groups}), 200
except Exception as e:
return _handle_error(e)
owner_type = request.args.get('owner_type')
owner_id = request.args.get('owner_id')
limit = request.args.get('limit', 100, type=int)
offset = request.args.get('offset', 0, type=int)
groups = QueueBusService.list_groups(
owner_type=owner_type,
owner_id=owner_id,
limit=limit,
offset=offset,
)
return jsonify({'groups': groups}), 200
@queue_bus_bp.route('/groups', methods=['POST'])
@jwt_required()
def create_group():
try:
data = request.get_json() or {}
slug = data.get('slug')
name = data.get('name')
description = data.get('description')
owner_type = data.get('owner_type', 'user')
owner_id = data.get('owner_id')
config = data.get('config')
data = json_object()
slug = data.get('slug')
name = data.get('name')
description = data.get('description')
owner_type = data.get('owner_type', 'user')
owner_id = data.get('owner_id')
config = data.get('config')
if not slug and not name:
return jsonify({'error': 'name or slug is required'}), 400
if not slug and not name:
return jsonify({'error': 'name or slug is required'}), 400
user = get_current_user()
if not _is_admin(user):
# Non-admins create under their own user ownership.
owner_type = 'user'
owner_id = str(user.id) if user else None
user = get_current_user()
if not _is_admin(user):
# Non-admins create under their own user ownership.
owner_type = 'user'
owner_id = str(user.id) if user else None
group = QueueBusService.create_group(
slug=slug,
name=name,
description=description,
owner_type=owner_type,
owner_id=owner_id,
config=config,
)
return jsonify({'group': group}), 201
except Exception as e:
return _handle_error(e)
group = QueueBusService.create_group(
slug=slug,
name=name,
description=description,
owner_type=owner_type,
owner_id=owner_id,
config=config,
)
return jsonify({'group': group}), 201
@queue_bus_bp.route('/groups/<group_slug>', methods=['GET'])
@jwt_required()
def get_group(group_slug):
try:
group = QueueBusService.get_group(group_slug)
if not group:
return jsonify({'error': 'Group not found'}), 404
return jsonify({'group': group}), 200
except Exception as e:
return _handle_error(e)
group = QueueBusService.get_group(group_slug)
if not group:
return jsonify({'error': 'Group not found'}), 404
return jsonify({'group': group}), 200
@queue_bus_bp.route('/groups/<group_slug>', methods=['PATCH'])
@jwt_required()
def update_group(group_slug):
try:
_ensure_group_mutable(group_slug)
_ensure_group_accessible(group_slug)
data = request.get_json() or {}
group = QueueBusService.update_group(
slug=group_slug,
name=data.get('name'),
description=data.get('description'),
config=data.get('config'),
)
return jsonify({'group': group}), 200
except Exception as e:
return _handle_error(e)
_ensure_group_mutable(group_slug)
_ensure_group_accessible(group_slug)
data = json_object()
group = QueueBusService.update_group(
slug=group_slug,
name=data.get('name'),
description=data.get('description'),
config=data.get('config'),
)
return jsonify({'group': group}), 200
@queue_bus_bp.route('/groups/<group_slug>', methods=['DELETE'])
@jwt_required()
def delete_group(group_slug):
try:
_ensure_group_mutable(group_slug)
_ensure_group_accessible(group_slug)
result = QueueBusService.delete_group(group_slug)
return jsonify(result), 200
except Exception as e:
return _handle_error(e)
_ensure_group_mutable(group_slug)
_ensure_group_accessible(group_slug)
result = QueueBusService.delete_group(group_slug)
return jsonify(result), 200
# ----------------------------------------------------------------------
@@ -166,83 +138,68 @@ def delete_group(group_slug):
@queue_bus_bp.route('/groups/<group_slug>/queues', methods=['GET'])
@jwt_required()
def list_queues(group_slug):
try:
limit = request.args.get('limit', 100, type=int)
offset = request.args.get('offset', 0, type=int)
queues = QueueBusService.list_queues(group_slug, limit=limit, offset=offset)
return jsonify({'queues': queues}), 200
except Exception as e:
return _handle_error(e)
limit = request.args.get('limit', 100, type=int)
offset = request.args.get('offset', 0, type=int)
queues = QueueBusService.list_queues(group_slug, limit=limit, offset=offset)
return jsonify({'queues': queues}), 200
@queue_bus_bp.route('/groups/<group_slug>/queues', methods=['POST'])
@jwt_required()
def create_queue(group_slug):
try:
_ensure_group_mutable(group_slug)
_ensure_group_accessible(group_slug)
data = request.get_json() or {}
slug = data.get('slug')
name = data.get('name')
description = data.get('description')
config = data.get('config')
_ensure_group_mutable(group_slug)
_ensure_group_accessible(group_slug)
data = json_object()
slug = data.get('slug')
name = data.get('name')
description = data.get('description')
config = data.get('config')
if not slug and not name:
return jsonify({'error': 'name or slug is required'}), 400
if not slug and not name:
return jsonify({'error': 'name or slug is required'}), 400
queue = QueueBusService.create_queue(
group_slug=group_slug,
slug=slug,
name=name,
description=description,
config=config,
)
return jsonify({'queue': queue}), 201
except Exception as e:
return _handle_error(e)
queue = QueueBusService.create_queue(
group_slug=group_slug,
slug=slug,
name=name,
description=description,
config=config,
)
return jsonify({'queue': queue}), 201
@queue_bus_bp.route('/groups/<group_slug>/queues/<queue_slug>', methods=['GET'])
@jwt_required()
def get_queue(group_slug, queue_slug):
try:
queue = QueueBusService.get_queue(group_slug, queue_slug)
if not queue:
return jsonify({'error': 'Queue not found'}), 404
return jsonify({'queue': queue}), 200
except Exception as e:
return _handle_error(e)
queue = QueueBusService.get_queue(group_slug, queue_slug)
if not queue:
return jsonify({'error': 'Queue not found'}), 404
return jsonify({'queue': queue}), 200
@queue_bus_bp.route('/groups/<group_slug>/queues/<queue_slug>', methods=['PATCH'])
@jwt_required()
def update_queue(group_slug, queue_slug):
try:
_ensure_group_mutable(group_slug)
_ensure_group_accessible(group_slug)
data = request.get_json() or {}
queue = QueueBusService.update_queue(
group_slug=group_slug,
slug=queue_slug,
name=data.get('name'),
description=data.get('description'),
config=data.get('config'),
)
return jsonify({'queue': queue}), 200
except Exception as e:
return _handle_error(e)
_ensure_group_mutable(group_slug)
_ensure_group_accessible(group_slug)
data = json_object()
queue = QueueBusService.update_queue(
group_slug=group_slug,
slug=queue_slug,
name=data.get('name'),
description=data.get('description'),
config=data.get('config'),
)
return jsonify({'queue': queue}), 200
@queue_bus_bp.route('/groups/<group_slug>/queues/<queue_slug>', methods=['DELETE'])
@jwt_required()
def delete_queue(group_slug, queue_slug):
try:
_ensure_group_mutable(group_slug)
_ensure_group_accessible(group_slug)
result = QueueBusService.delete_queue(group_slug, queue_slug)
return jsonify(result), 200
except Exception as e:
return _handle_error(e)
_ensure_group_mutable(group_slug)
_ensure_group_accessible(group_slug)
result = QueueBusService.delete_queue(group_slug, queue_slug)
return jsonify(result), 200
# ----------------------------------------------------------------------
@@ -252,135 +209,111 @@ def delete_queue(group_slug, queue_slug):
@queue_bus_bp.route('/groups/<group_slug>/queues/<queue_slug>/messages', methods=['GET'])
@jwt_required()
def list_messages(group_slug, queue_slug):
try:
_ensure_group_accessible(group_slug)
status = request.args.get('status')
limit = request.args.get('limit', 100, type=int)
offset = request.args.get('offset', 0, type=int)
messages = QueueBusService.list_messages(
group_slug=group_slug,
queue_slug=queue_slug,
status=status,
limit=limit,
offset=offset,
)
return jsonify({'messages': messages}), 200
except Exception as e:
return _handle_error(e)
_ensure_group_accessible(group_slug)
status = request.args.get('status')
limit = request.args.get('limit', 100, type=int)
offset = request.args.get('offset', 0, type=int)
messages = QueueBusService.list_messages(
group_slug=group_slug,
queue_slug=queue_slug,
status=status,
limit=limit,
offset=offset,
)
return jsonify({'messages': messages}), 200
@queue_bus_bp.route('/groups/<group_slug>/queues/<queue_slug>/messages', methods=['POST'])
@jwt_required()
def send_message(group_slug, queue_slug):
try:
_ensure_group_mutable(group_slug)
_ensure_group_accessible(group_slug)
data = request.get_json() or {}
payload = data.get('payload')
priority = data.get('priority', 0)
delay_ms = data.get('delay_ms', 0)
max_attempts = data.get('max_attempts')
_ensure_group_mutable(group_slug)
_ensure_group_accessible(group_slug)
data = json_object()
payload = data.get('payload')
priority = data.get('priority', 0)
delay_ms = data.get('delay_ms', 0)
max_attempts = data.get('max_attempts')
if payload is None:
return jsonify({'error': 'payload is required'}), 400
if payload is None:
return jsonify({'error': 'payload is required'}), 400
message = QueueBusService.send(
group_slug=group_slug,
queue_slug=queue_slug,
payload=payload,
priority=priority,
delay_ms=delay_ms,
max_attempts=max_attempts,
)
return jsonify({'message': message}), 201
except Exception as e:
return _handle_error(e)
message = QueueBusService.send(
group_slug=group_slug,
queue_slug=queue_slug,
payload=payload,
priority=priority,
delay_ms=delay_ms,
max_attempts=max_attempts,
)
return jsonify({'message': message}), 201
@queue_bus_bp.route('/groups/<group_slug>/queues/<queue_slug>/messages/receive', methods=['POST'])
@jwt_required()
def receive_messages(group_slug, queue_slug):
try:
_ensure_group_accessible(group_slug)
data = request.get_json() or {}
visibility_timeout_ms = data.get('visibility_timeout_ms', 30000)
max_messages = data.get('max_messages', 1)
_ensure_group_accessible(group_slug)
data = json_object()
visibility_timeout_ms = data.get('visibility_timeout_ms', 30000)
max_messages = data.get('max_messages', 1)
messages = QueueBusService.receive(
group_slug=group_slug,
queue_slug=queue_slug,
visibility_timeout_ms=visibility_timeout_ms,
max_messages=max_messages,
)
return jsonify({'messages': messages}), 200
except Exception as e:
return _handle_error(e)
messages = QueueBusService.receive(
group_slug=group_slug,
queue_slug=queue_slug,
visibility_timeout_ms=visibility_timeout_ms,
max_messages=max_messages,
)
return jsonify({'messages': messages}), 200
@queue_bus_bp.route('/groups/<group_slug>/queues/<queue_slug>/messages/<message_id>', methods=['GET'])
@jwt_required()
def get_message(group_slug, queue_slug, message_id):
try:
_ensure_group_accessible(group_slug)
message = QueueBusService.get_message(group_slug, queue_slug, message_id)
if not message:
return jsonify({'error': 'Message not found'}), 404
return jsonify({'message': message}), 200
except Exception as e:
return _handle_error(e)
_ensure_group_accessible(group_slug)
message = QueueBusService.get_message(group_slug, queue_slug, message_id)
if not message:
return jsonify({'error': 'Message not found'}), 404
return jsonify({'message': message}), 200
@queue_bus_bp.route('/groups/<group_slug>/queues/<queue_slug>/messages/<message_id>/complete', methods=['POST'])
@jwt_required()
def complete_message(group_slug, queue_slug, message_id):
try:
_ensure_group_accessible(group_slug)
message = QueueBusService.complete(group_slug, queue_slug, message_id)
return jsonify({'message': message}), 200
except Exception as e:
return _handle_error(e)
_ensure_group_accessible(group_slug)
message = QueueBusService.complete(group_slug, queue_slug, message_id)
return jsonify({'message': message}), 200
@queue_bus_bp.route('/groups/<group_slug>/queues/<queue_slug>/messages/<message_id>/fail', methods=['POST'])
@jwt_required()
def fail_message(group_slug, queue_slug, message_id):
try:
_ensure_group_accessible(group_slug)
data = request.get_json() or {}
message = QueueBusService.fail(
group_slug=group_slug,
queue_slug=queue_slug,
message_id=message_id,
error_message=data.get('error_message'),
requeue=data.get('requeue', False),
)
return jsonify({'message': message}), 200
except Exception as e:
return _handle_error(e)
_ensure_group_accessible(group_slug)
data = json_object()
message = QueueBusService.fail(
group_slug=group_slug,
queue_slug=queue_slug,
message_id=message_id,
error_message=data.get('error_message'),
requeue=data.get('requeue', False),
)
return jsonify({'message': message}), 200
@queue_bus_bp.route('/groups/<group_slug>/queues/<queue_slug>/messages/<message_id>/requeue', methods=['POST'])
@jwt_required()
def requeue_message(group_slug, queue_slug, message_id):
try:
_ensure_group_mutable(group_slug)
_ensure_group_accessible(group_slug)
message = QueueBusService.requeue(group_slug, queue_slug, message_id)
return jsonify({'message': message}), 200
except Exception as e:
return _handle_error(e)
_ensure_group_mutable(group_slug)
_ensure_group_accessible(group_slug)
message = QueueBusService.requeue(group_slug, queue_slug, message_id)
return jsonify({'message': message}), 200
@queue_bus_bp.route('/groups/<group_slug>/queues/<queue_slug>/messages/<message_id>', methods=['DELETE'])
@jwt_required()
def delete_message(group_slug, queue_slug, message_id):
try:
_ensure_group_mutable(group_slug)
_ensure_group_accessible(group_slug)
result = QueueBusService.delete_message(group_slug, queue_slug, message_id)
return jsonify(result), 200
except Exception as e:
return _handle_error(e)
_ensure_group_mutable(group_slug)
_ensure_group_accessible(group_slug)
result = QueueBusService.delete_message(group_slug, queue_slug, message_id)
return jsonify(result), 200
# ----------------------------------------------------------------------
@@ -406,28 +339,19 @@ def receive_messages_shorthand(group_slug, queue_slug):
@queue_bus_bp.route('/stats', methods=['GET'])
@jwt_required()
def global_stats():
try:
stats = QueueBusService.get_stats()
return jsonify(stats), 200
except Exception as e:
return _handle_error(e)
stats = QueueBusService.get_stats()
return jsonify(stats), 200
@queue_bus_bp.route('/groups/<group_slug>/stats', methods=['GET'])
@jwt_required()
def group_stats(group_slug):
try:
stats = QueueBusService.get_stats(group_slug=group_slug)
return jsonify(stats), 200
except Exception as e:
return _handle_error(e)
stats = QueueBusService.get_stats(group_slug=group_slug)
return jsonify(stats), 200
@queue_bus_bp.route('/groups/<group_slug>/queues/<queue_slug>/stats', methods=['GET'])
@jwt_required()
def queue_stats(group_slug, queue_slug):
try:
stats = QueueBusService.get_stats(group_slug=group_slug, queue_slug=queue_slug)
return jsonify(stats), 200
except Exception as e:
return _handle_error(e)
stats = QueueBusService.get_stats(group_slug=group_slug, queue_slug=queue_slug)
return jsonify(stats), 200
+4 -3
View File
@@ -1,4 +1,4 @@
"""Recipe run API (plan 68).
"""Recipe run API.
Starts runs from three manifest sources — a registry slug (the curated
``serverkit-recipes`` catalog), reviewed inline YAML/JSON, or a manifest
@@ -23,6 +23,7 @@ from app.services.recipe_execution_service import (
RECIPE_JOB_KIND,
RecipeExecutionService,
)
from app.api._request import json_object
recipes_bp = Blueprint('recipes', __name__)
@@ -89,7 +90,7 @@ def get_recipe_registry_entry(slug):
@recipes_bp.route('/runs', methods=['POST'])
@admin_required
def start_recipe_run():
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
project = None
server = None
@@ -164,7 +165,7 @@ def submit_recipe_handoff(job_id, step_id):
job = DeploymentJob.query.get(job_id)
if not job or job.kind != RECIPE_JOB_KIND:
return jsonify({'error': 'Recipe run not found'}), 404
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
user = get_current_user()
wait = request.args.get('wait', 'false').lower() == 'true'
result = RecipeExecutionService.submit_handoff(
+2 -1
View File
@@ -9,6 +9,7 @@ from flask_jwt_extended import jwt_required, get_jwt_identity
from app.middleware.rbac import admin_required
from app.exceptions import NotFoundError
from app.services import recycle_bin_service
from app.api._request import json_object
recycle_bin_bp = Blueprint('recycle_bin', __name__)
@@ -63,7 +64,7 @@ def purge(kind, record_id):
@recycle_bin_bp.route('/purge-expired', methods=['POST'])
@admin_required
def purge_expired():
body = request.get_json(silent=True) or {}
body = json_object(optional=True)
days = body.get('retention_days', recycle_bin_service.DEFAULT_RETENTION_DAYS)
try:
days = max(1, int(days))
+3 -2
View File
@@ -5,11 +5,12 @@ available to any authenticated user so the Domains page and Connections cards
can show "what we own and when it lapses".
"""
from flask import Blueprint, request, jsonify
from flask import Blueprint, jsonify
from flask_jwt_extended import jwt_required
from app.services.registrar_service import RegistrarService
from app.middleware.rbac import admin_required, get_current_user
from app.api._request import json_object
registrars_bp = Blueprint('registrars', __name__)
@@ -27,7 +28,7 @@ def add_connection():
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json() or {}
data = json_object()
try:
conn = RegistrarService.add_connection(data, user.id)
except ValueError as e:
+1 -1
View File
@@ -1,4 +1,4 @@
"""Generic restore-point lifecycle API (Plan 81, M3).
"""Generic restore-point lifecycle API.
Application-owned scopes use the existing ResourceGrant seam. Host-level
surfaces are operational and require a developer role even when their local
+1 -1
View File
@@ -1,4 +1,4 @@
"""Run envelope REST twin (plan 77 E1).
"""Run envelope REST twin.
The polling counterpart of the `run_log` / `run_status` socket events: any
run kind's persisted lines, resumable via ?after_id, so a client that missed
+1 -1
View File
@@ -1,4 +1,4 @@
"""Unified entity omnisearch API (plan 41, Phase 4).
"""Unified entity omnisearch API.
A single authz-aware endpoint the command palette calls to search across the
core entity types (services, servers, domains, databases, WordPress sites, cron
+1 -3
View File
@@ -8,7 +8,7 @@ from app.middleware.rbac import get_current_user, require_workspace_access, requ
from app.services.secret_vault_service import SecretService, SecretVaultService
from app.services.webhook_gateway_service import WebhookGatewayService
from app.services.workspace_service import WorkspaceService
from app.models import User, Workspace
from app.models import Workspace
bp = Blueprint('secrets_webhooks', __name__)
@@ -30,8 +30,6 @@ def _resolve_ws():
?workspace_id) to a workspace id, or None for an unscoped view — mirrors the
servers/apps scoping (#33). Lenient: a stale/unknown context degrades to
None rather than erroring."""
from app.models import User
from app.services.workspace_service import WorkspaceService
user = get_current_user()
return WorkspaceService.resolve_workspace_id(user, _resolve_ws_value())
+9 -8
View File
@@ -1,7 +1,7 @@
# Bucket: PER-APP (plan 29 #9). Per-app scan/security routes gate on the shared
# Bucket: PER-APP. Per-app scan/security routes gate on the shared
# app-access seam; host-level surfaces stay admin-only.
#
# Lean core (plan 47 Ph3b-4 / plan 55 Phase 3): this blueprint keeps only the
# Lean core: this blueprint keeps only the
# baseline that works with zero host packages — status/config, legacy
# integrity, suspicious activity, events, SSH keys, IP lists, the audit and
# scoped FIM. The install-gated tool surfaces (/clamav/*, /scan/*,
@@ -14,6 +14,7 @@ from flask_jwt_extended import jwt_required
from app.middleware.rbac import admin_required
from app.services.security_service import SecurityService
from app.error_reporting import unexpected_response
from app.api._request import json_object
security_bp = Blueprint('security', __name__)
@@ -41,7 +42,7 @@ def get_config():
@admin_required
def update_config():
"""Update security configuration."""
data = request.get_json()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
@@ -63,7 +64,7 @@ def update_config():
@admin_required
def initialize_integrity():
"""Create baseline for file integrity monitoring."""
data = request.get_json() or {}
data = json_object()
paths = data.get('paths')
result = SecurityService.initialize_integrity_database(paths)
return jsonify(result), 200 if result['success'] else 400
@@ -117,7 +118,7 @@ def get_ssh_keys():
@admin_required
def add_ssh_key():
"""Add an SSH public key."""
data = request.get_json()
data = json_object()
if not data or 'key' not in data:
return jsonify({'error': 'SSH key required'}), 400
@@ -150,7 +151,7 @@ def get_ip_lists():
@admin_required
def add_to_ip_list(list_type):
"""Add IP to allowlist or blocklist."""
data = request.get_json()
data = json_object()
if not data or 'ip' not in data:
return jsonify({'error': 'IP address required'}), 400
@@ -199,7 +200,7 @@ def fim_baseline(scope):
from app.services.file_integrity_service import (
FileIntegrityService, FileIntegrityScopeError,
)
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
options = data.get('options') if isinstance(data.get('options'), dict) else None
try:
result = FileIntegrityService.baseline(scope, options=options)
@@ -249,7 +250,7 @@ def fim_set_app_optins():
from app.services.file_integrity_service import (
FileIntegrityService, FileIntegrityScopeError,
)
data = request.get_json(silent=True)
data = json_object(optional=True)
if not data or 'app_ids' not in data:
return jsonify({'error': 'app_ids required'}), 400
try:
+5 -4
View File
@@ -5,6 +5,7 @@ from app.services.audit_service import AuditService
from app.models.audit_log import AuditLog
from app.middleware.rbac import admin_required, get_current_user
from app.exceptions import not_found
from app.api._request import json_object
server_templates_bp = Blueprint('server_templates', __name__)
@@ -52,7 +53,7 @@ def create_template():
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json()
data = json_object()
if not data or 'name' not in data:
return jsonify({'error': 'name required'}), 400
@@ -75,7 +76,7 @@ def update_template(template_id):
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json()
data = json_object()
template = ServerTemplateService.update_template(template_id, data)
if not template:
raise not_found('template')
@@ -106,7 +107,7 @@ def assign_template(template_id):
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json() or {}
data = json_object()
server_id = data.get('server_id')
if not server_id:
return jsonify({'error': 'server_id required'}), 400
@@ -125,7 +126,7 @@ def bulk_assign(template_id):
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json() or {}
data = json_object()
server_ids = data.get('server_ids', [])
results = ServerTemplateService.bulk_assign(template_id, server_ids)
return jsonify({'results': results})
+41 -1754
View File
File diff suppressed because it is too large. Load diff
+706
View File
@@ -0,0 +1,706 @@
"""Agent install scripts, agent updates and fleet rollout routes.
Registered on the shared ``servers_bp`` blueprint (see app.api.servers)."""
import os
import re
import requests
from app.models.server import AgentVersion, Server
from flask import Response, current_app, jsonify, redirect, request
from app.middleware.rbac import admin_required, developer_required
from app.exceptions import agent_offline, field_required, not_found
from app.services.agent_registry import agent_registry
from datetime import datetime, timedelta
from app import db
from app.services.discovery_service import discovery_service
from app.services.remote_command_dispatcher import dispatch_agent_command
from app.services.agent_fleet_service import fleet_service
from flask_jwt_extended import get_jwt_identity, jwt_required
from app.api._request import json_object
from app.api.servers import _get_external_base_url, servers_bp
# ==================== Installation Scripts ====================
def _get_scripts_dir():
"""Resolve the directory holding the fleet-agent installer scripts.
Every shipped layout has to land on a real directory here:
* git checkout / release tarball -> ``<tree root>/scripts``
* Docker image -> ``/app/scripts``, which exists only
because the Dockerfile explicitly copies the two installers there.
``SERVERKIT_SCRIPTS_DIR`` overrides the guess for layouts that split the
backend away from the rest of the tree.
"""
override = os.environ.get('SERVERKIT_SCRIPTS_DIR')
if override:
return override
# Go up from backend/app/api to backend, then to scripts
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
return os.path.join(base_dir, 'scripts')
def _load_installer(filename):
"""Read an agent installer off disk and stamp this panel's coordinates in.
Returns ``(content, None)`` on success or ``(None, error_response)`` when the
file is absent, so the two endpoints below cannot drift apart.
"""
script_path = os.path.join(_get_scripts_dir(), filename)
if not os.path.exists(script_path):
# A missing installer is a packaging fault on THIS panel, never a bad
# request. The old 404 asserted "no such URL", which is exactly what sent
# issue #101's reporter hunting for a wrong path -- they filed it as
# "incorrect paths in setup" -- when the file had simply never been
# shipped into the image. 503 says "this server is broken, not your URL",
# and the log line names the path actually searched so an operator can
# confirm it in one look instead of guessing.
current_app.logger.error(
'Agent installer %s is missing from this deployment (searched %s). '
'Fleet enrollment via /api/v1/servers/%s cannot work until it is '
'restored; see docs/ARCHITECTURE.md.',
filename, script_path, filename
)
return None, (jsonify({
'error': f'{filename} is not present on this ServerKit panel',
'detail': (
'The panel serves this installer from its own filesystem and the '
'file is missing, so this is a packaging/deployment problem with '
'this panel rather than a wrong URL. The panel log records the '
'exact path searched.'
),
'searched_path': script_path,
}), 503)
with open(script_path, 'r', encoding='utf-8') as f:
content = f.read()
# Replace placeholders with actual values
content = content.replace('https://your-serverkit.com', _get_external_base_url())
content = content.replace('jhd3197/serverkit-agent', AGENT_GITHUB_REPO)
return content, None
@servers_bp.route('/install.sh', methods=['GET'])
def get_install_script_linux():
"""
Get the Linux installation script.
This endpoint serves the bash installation script for installing
the ServerKit agent on Linux systems.
Usage (download-then-run, not `curl | bash`: a pipeline reports bash's exit
status, so a failed download would exit 0 having installed nothing):
curl -fsSL https://your-server/api/v1/servers/install.sh \\
-o /tmp/serverkit-agent-install.sh \\
&& sudo bash /tmp/serverkit-agent-install.sh \\
--token "YOUR_TOKEN" --server "https://your-server"
"""
content, error = _load_installer('install.sh')
if error is not None:
return error
# Inject the agent version the panel already resolved so enrollment does
# not depend on the installer rediscovering it via the GitHub API. Best
# effort: on failure the placeholder stays empty and the script falls
# back to its own GitHub discovery.
release = _get_latest_agent_release()
version = (release or {}).get('version') or ''
if version and all(c.isalnum() or c in '._-' for c in version):
content = content.replace(
'SERVERKIT_AGENT_VERSION=""',
f'SERVERKIT_AGENT_VERSION="{version}"',
1
)
return Response(
content,
mimetype='text/x-shellscript',
headers={
'Content-Disposition': 'inline; filename="install.sh"',
'Cache-Control': 'no-cache'
}
)
@servers_bp.route('/install.ps1', methods=['GET'])
def get_install_script_windows():
"""
Get the Windows installation script.
This endpoint serves the PowerShell installation script for installing
the ServerKit agent on Windows systems.
Usage:
irm https://your-server/api/v1/servers/install.ps1 | iex; \\
Install-ServerKitAgent -Token "YOUR_TOKEN" -Server "https://your-server"
"""
content, error = _load_installer('install.ps1')
if error is not None:
return error
return Response(
content,
mimetype='text/plain',
headers={
'Content-Disposition': 'inline; filename="install.ps1"',
'Cache-Control': 'no-cache'
}
)
@servers_bp.route('/install-instructions/<server_id>', methods=['GET'])
@jwt_required()
def get_install_instructions(server_id):
"""
Get installation instructions for a specific server.
Returns installation commands with the correct API endpoint. Registration
tokens are only shown when they are generated, so the UI supplies the token.
"""
server = Server.query.get(server_id)
if not server:
raise not_found('server')
# Check if server has a valid registration token
if not server.registration_token_hash:
return jsonify({
'error': 'No registration token available',
'message': 'Generate a new token using the regenerate-token endpoint'
}), 400
if server.registration_token_expires and server.registration_token_expires < datetime.utcnow():
return jsonify({
'error': 'Registration token expired',
'message': 'Generate a new token using the regenerate-token endpoint'
}), 400
# Get base URL
base_url = _get_external_base_url()
api_url = f"{base_url}/api/v1/servers"
return jsonify({
'linux': {
# Download-then-run rather than `curl … | sudo bash`: in a pipeline
# the shell reports bash's exit status, so a failed download exits 0
# and installs nothing without saying so (issue #101).
'one_liner': (
f'curl -fsSL {api_url}/install.sh -o /tmp/serverkit-agent-install.sh '
f'&& sudo bash /tmp/serverkit-agent-install.sh '
f'--token "YOUR_TOKEN" --server "{base_url}"'
),
'manual': [
f'# Download the script',
f'curl -fsSL {api_url}/install.sh -o install.sh',
f'chmod +x install.sh',
f'',
f'# Run installation',
f'sudo ./install.sh --token "YOUR_TOKEN" --server "{base_url}"'
]
},
'windows': {
'one_liner': f'irm {api_url}/install.ps1 | iex; Install-ServerKitAgent -Token "YOUR_TOKEN" -Server "{base_url}"',
'manual': [
f'# Download the script (run in PowerShell as Administrator)',
f'Invoke-WebRequest -Uri "{api_url}/install.ps1" -OutFile install.ps1',
f'',
f'# Run installation',
f'.\\install.ps1 -Token "YOUR_TOKEN" -Server "{base_url}"'
]
},
'note': 'Replace YOUR_TOKEN with the registration token shown in the UI'
})
# ==================== Agent Updates ====================
# Cache for GitHub releases to avoid rate limiting
_releases_cache = {
'data': None,
'expires': None
}
# The agent is built and released from its own repo, tagged plain `vX.Y.Z`. It
# used to ship from this monorepo under `agent-v*` tags; when it moved out,
# nothing here followed it, so this lookup searched a repo that has no agent
# releases at all -- /agent/version answered 503 for every agent polling for an
# update, and the served installer downloaded from a 404. Point both at the
# repo that actually publishes the binaries.
AGENT_GITHUB_REPO = os.environ.get('SERVERKIT_AGENT_GITHUB_REPO', 'jhd3197/serverkit-agent')
# Matches vX.Y.Z but not the malformed release tagged just "v" that sits in the
# agent repo's release list -- that one parses to an empty version and builds
# download URLs like .../releases/download/v/serverkit-agent--linux-amd64.tar.gz.
_AGENT_TAG_RE = re.compile(r'^v(\d[^\s]*)$')
def _get_latest_agent_release():
"""Fetch latest agent release from GitHub with caching"""
now = datetime.utcnow()
# Check cache
if _releases_cache['data'] and _releases_cache['expires'] and _releases_cache['expires'] > now:
return _releases_cache['data']
try:
# Every release in the agent repo is an agent release, but ask for 100
# anyway so a run of pre-release tags cannot hide the newest stable one.
response = requests.get(
f'https://api.github.com/repos/{AGENT_GITHUB_REPO}/releases',
headers={'Accept': 'application/vnd.github.v3+json'},
params={'per_page': 100},
timeout=10
)
response.raise_for_status()
releases = response.json()
# Find latest agent release
for release in releases:
match = _AGENT_TAG_RE.match(release.get('tag_name', ''))
if match:
version = match.group(1)
# Build assets map
assets = {}
for asset in release.get('assets', []):
name = asset['name']
if 'linux-amd64' in name:
assets['linux-amd64'] = asset['browser_download_url']
elif 'linux-arm64' in name:
assets['linux-arm64'] = asset['browser_download_url']
elif 'windows-amd64' in name:
assets['windows-amd64'] = asset['browser_download_url']
elif name == 'checksums.txt':
assets['checksums'] = asset['browser_download_url']
result = {
'version': version,
'tag': release['tag_name'],
'published_at': release['published_at'],
'release_url': release['html_url'],
'assets': assets,
'body': release.get('body', '')
}
# Cache for 5 minutes
_releases_cache['data'] = result
_releases_cache['expires'] = now + timedelta(minutes=5)
return result
return None
except Exception as e:
current_app.logger.error(f"Failed to fetch GitHub releases: {e}")
# Return cached data if available, even if expired
if _releases_cache['data']:
return _releases_cache['data']
return None
@servers_bp.route('/agent/version', methods=['GET'])
def get_agent_version():
"""
Get the latest agent version information.
This endpoint is called by agents to check for updates.
Returns version info and download URLs for all platforms.
"""
release = _get_latest_agent_release()
if not release:
return jsonify({
'error': 'Unable to fetch version information',
'message': 'GitHub API may be unavailable'
}), 503
# Get base URL for local downloads (fallback)
base_url = request.url_root.rstrip('/')
return jsonify({
'version': release['version'],
'published_at': release['published_at'],
'release_notes_url': release['release_url'],
'downloads': {
'linux-amd64': release['assets'].get('linux-amd64'),
'linux-arm64': release['assets'].get('linux-arm64'),
'windows-amd64': release['assets'].get('windows-amd64'),
},
'checksums_url': release['assets'].get('checksums'),
'update_available_message': f"A new version of ServerKit Agent is available: v{release['version']}"
})
@servers_bp.route('/agent/version/check', methods=['POST'])
def check_agent_version():
"""
Check if an agent needs to be updated.
Called by agents with their current version to check if an update is needed.
Request body:
{
"current_version": "1.0.0",
"os": "linux",
"arch": "amd64"
}
"""
data = json_object()
current_version = data.get('current_version', '0.0.0')
agent_os = data.get('os', 'linux')
agent_arch = data.get('arch', 'amd64')
release = _get_latest_agent_release()
if not release:
return jsonify({
'update_available': False,
'error': 'Unable to check for updates'
})
latest_version = release['version']
# Compare versions (simple string comparison works for semver)
update_available = _compare_versions(current_version, latest_version) < 0
platform_key = f"{agent_os}-{agent_arch}"
download_url = release['assets'].get(platform_key)
return jsonify({
'update_available': update_available,
'current_version': current_version,
'latest_version': latest_version,
'download_url': download_url,
'checksums_url': release['assets'].get('checksums'),
'release_notes_url': release['release_url'],
'published_at': release['published_at']
})
def _compare_versions(v1, v2):
"""
Compare two semantic versions.
Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
"""
def parse_version(v):
# Remove leading 'v' if present
v = v.lstrip('v')
# Split by dots and convert to integers
parts = []
for part in v.split('.'):
# Handle pre-release versions like 1.0.0-beta.1
if '-' in part:
num, _ = part.split('-', 1)
parts.append(int(num) if num.isdigit() else 0)
else:
parts.append(int(part) if part.isdigit() else 0)
# Pad to at least 3 parts
while len(parts) < 3:
parts.append(0)
return parts
try:
p1 = parse_version(v1)
p2 = parse_version(v2)
for a, b in zip(p1, p2):
if a < b:
return -1
elif a > b:
return 1
return 0
except:
return 0
@servers_bp.route('/agent/download/<os_name>/<arch>', methods=['GET'])
def download_agent(os_name, arch):
"""
Redirect to the appropriate agent download.
This endpoint redirects to the GitHub release asset for the
requested OS and architecture.
"""
# Validate inputs
valid_os = ['linux', 'windows', 'darwin']
valid_arch = ['amd64', 'arm64']
if os_name not in valid_os:
return jsonify({'error': f'Invalid OS. Valid options: {valid_os}'}), 400
if arch not in valid_arch:
return jsonify({'error': f'Invalid architecture. Valid options: {valid_arch}'}), 400
release = _get_latest_agent_release()
if not release:
return jsonify({'error': 'Unable to fetch release information'}), 503
platform_key = f"{os_name}-{arch}"
download_url = release['assets'].get(platform_key)
if not download_url:
return jsonify({
'error': f'No release available for {os_name}-{arch}',
'available': list(release['assets'].keys())
}), 404
# Redirect to GitHub release
return redirect(download_url, code=302)
@servers_bp.route('/agent/checksums', methods=['GET'])
def get_agent_checksums():
"""
Get SHA256 checksums for agent binaries.
Returns the checksums.txt content from the latest release.
"""
release = _get_latest_agent_release()
if not release or not release['assets'].get('checksums'):
return jsonify({'error': 'Checksums not available'}), 404
try:
response = requests.get(release['assets']['checksums'], timeout=10)
response.raise_for_status()
return Response(
response.text,
mimetype='text/plain',
headers={
'Content-Disposition': 'inline; filename="checksums.txt"',
'X-Agent-Version': release['version']
}
)
except Exception as e:
current_app.logger.error(f"Failed to fetch checksums: {e}")
return jsonify({'error': 'Failed to fetch checksums'}), 503
@servers_bp.route('/<server_id>/agent/update', methods=['POST'])
@developer_required
def trigger_agent_update(server_id):
"""
Trigger an agent update on a specific server.
Sends a command to the agent to check for and install updates.
Developer role required — this replaces the agent binary and restarts the
service across the fleet, so it must not be reachable by read-only viewers
(every other state-changing server route is already @developer_required).
"""
server = Server.query.get(server_id)
if not server:
raise not_found('server')
if not agent_registry.is_agent_connected(server_id):
raise agent_offline()
# Get latest version info
release = _get_latest_agent_release()
if not release:
return jsonify({
'success': False,
'error': 'Unable to fetch latest version'
}), 503
# Send update command to agent
result = dispatch_agent_command(
server_id=server_id,
action='agent:update',
params={
'version': release['version'],
'force': json_object(optional=True).get('force', False)
},
timeout=60.0 # Updates may take a while
)
return jsonify(result)
# ==================== Agent Fleet Management ====================
@servers_bp.route('/fleet/health', methods=['GET'])
@admin_required
def get_fleet_health():
"""Get aggregated health metrics for the agent fleet"""
return jsonify(fleet_service.get_fleet_health())
@servers_bp.route('/fleet/versions', methods=['GET'])
@admin_required
def list_agent_versions():
"""List all available agent versions"""
versions = AgentVersion.query.order_by(AgentVersion.version.desc()).all()
return jsonify([v.to_dict() for v in versions])
@servers_bp.route('/fleet/versions', methods=['POST'])
@admin_required
def add_agent_version():
"""Add a new available agent version"""
data = json_object()
if not data.get('version'):
raise field_required('Version')
version = AgentVersion(
version=data['version'],
channel=data.get('channel', 'stable'),
min_panel_version=data.get('min_panel_version'),
max_panel_version=data.get('max_panel_version'),
release_notes=data.get('release_notes'),
assets=data.get('assets', {}),
published_at=datetime.fromisoformat(data['published_at']) if data.get('published_at') else datetime.utcnow()
)
db.session.add(version)
db.session.commit()
return jsonify(version.to_dict()), 201
@servers_bp.route('/fleet/upgrade', methods=['POST'])
@admin_required
def upgrade_fleet():
"""Trigger upgrade for selected servers or entire fleet"""
data = json_object()
server_ids = data.get('server_ids', [])
version_id = data.get('version_id')
user_id = get_jwt_identity()
if not server_ids:
# If no IDs provided, upgrade all online servers
servers = Server.query.filter_by(status='online').all()
server_ids = [s.id for s in servers]
if not server_ids:
return jsonify({'success': True, 'message': 'No online servers to upgrade'})
result = fleet_service.upgrade_servers(server_ids, version_id, user_id)
return jsonify(result)
@servers_bp.route('/fleet/rollout', methods=['POST'])
@admin_required
def start_staged_rollout():
"""Start a staged rollout"""
data = json_object()
group_id = data.get('group_id')
version_id = data.get('version_id')
batch_size = data.get('batch_size', 5)
delay_minutes = data.get('delay_minutes', 10)
strategy = data.get('strategy', 'staged')
server_ids = data.get('server_ids')
user_id = get_jwt_identity()
if not version_id:
raise field_required('version_id')
result = fleet_service.staged_rollout(
group_id, version_id, batch_size, delay_minutes,
strategy, user_id, server_ids
)
return jsonify(result)
@servers_bp.route('/fleet/rollouts', methods=['GET'])
@admin_required
def list_rollouts():
"""List rollout history"""
status = request.args.get('status')
limit = request.args.get('limit', 20, type=int)
return jsonify(fleet_service.get_rollouts(status, limit))
@servers_bp.route('/fleet/rollouts/<rollout_id>', methods=['GET'])
@admin_required
def get_rollout(rollout_id):
"""Get a specific rollout"""
rollout = fleet_service.get_rollout(rollout_id)
if not rollout:
return jsonify({'error': 'Rollout not found'}), 404
return jsonify(rollout)
@servers_bp.route('/fleet/rollouts/<rollout_id>/cancel', methods=['POST'])
@admin_required
def cancel_rollout(rollout_id):
"""Cancel an active rollout"""
success = fleet_service.cancel_rollout(rollout_id)
if not success:
return jsonify({'error': 'Cannot cancel rollout (not running or not found)'}), 400
return jsonify({'success': True, 'message': 'Rollout cancelled'})
@servers_bp.route('/fleet/discovery', methods=['POST'])
@admin_required
def start_discovery_scan():
"""Start a network scan for new agents"""
duration = request.args.get('duration', 10, type=int)
agents = discovery_service.start_scan(duration)
return jsonify(agents)
@servers_bp.route('/fleet/discovery', methods=['GET'])
@admin_required
def get_discovered_agents():
"""Get results of last discovery scan"""
return jsonify(discovery_service.get_discovered_agents())
@servers_bp.route('/fleet/approve/<server_id>', methods=['POST'])
@admin_required
def approve_agent_registration(server_id):
"""Approve a pending agent registration"""
user_id = get_jwt_identity()
success = fleet_service.approve_registration(server_id, user_id)
if not success:
return jsonify({'error': 'Failed to approve registration'}), 400
return jsonify({'success': True, 'message': 'Registration approved'})
@servers_bp.route('/fleet/reject/<server_id>', methods=['POST'])
@admin_required
def reject_agent_registration(server_id):
"""Reject a pending agent registration"""
success = fleet_service.reject_registration(server_id)
if not success:
return jsonify({'error': 'Failed to reject registration'}), 400
return jsonify({'success': True, 'message': 'Registration rejected'})
@servers_bp.route('/fleet/commands/queued', methods=['GET'])
@admin_required
def get_queued_commands():
"""Get all pending queued commands"""
server_id = request.args.get('server_id')
commands = fleet_service.get_queued_commands(server_id)
return jsonify(commands)
@servers_bp.route('/fleet/commands/<command_id>/retry', methods=['POST'])
@admin_required
def retry_command(command_id):
"""Retry a failed command"""
result = fleet_service.retry_command(command_id)
if not result.get('success'):
return jsonify(result), 400
return jsonify(result)
@servers_bp.route('/fleet/diagnostics/<server_id>', methods=['GET'])
@admin_required
def get_server_diagnostics(server_id):
"""Get detailed connection diagnostics for a server"""
diagnostics = fleet_service.get_server_diagnostics(server_id)
if 'error' in diagnostics:
return jsonify(diagnostics), 404
return jsonify(diagnostics)
+446
View File
@@ -0,0 +1,446 @@
"""Remote Docker and Compose routes for a paired server.
Registered on the shared ``servers_bp`` blueprint (see app.api.servers)."""
from app.services.remote_docker_service import RemoteDockerService
from app.middleware.rbac import developer_required
from app.exceptions import field_required
from flask_jwt_extended import get_jwt_identity, jwt_required
from flask import jsonify, request
from app.api.servers import _agent_failure_status, servers_bp
from app.api._request import json_object
# ==================== Remote Docker Operations ====================
@servers_bp.route('/available', methods=['GET'])
@jwt_required()
def get_available_servers():
"""Get list of servers available for Docker operations"""
servers = RemoteDockerService.get_available_servers()
return jsonify(servers)
@servers_bp.route('/<server_id>/docker/containers', methods=['GET'])
@jwt_required()
def list_remote_containers(server_id):
"""List containers on a remote server"""
all_containers = request.args.get('all', 'false').lower() == 'true'
user_id = get_jwt_identity()
result = RemoteDockerService.list_containers(server_id, all=all_containers, user_id=user_id)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify(result.get('data', []))
@servers_bp.route('/<server_id>/docker/containers/<container_id>', methods=['GET'])
@jwt_required()
def inspect_remote_container(server_id, container_id):
"""Inspect a container on a remote server"""
user_id = get_jwt_identity()
result = RemoteDockerService.inspect_container(server_id, container_id, user_id=user_id)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify(result.get('data'))
@servers_bp.route('/<server_id>/docker/containers/<container_id>/start', methods=['POST'])
@developer_required
def start_remote_container(server_id, container_id):
"""Start a container on a remote server"""
user_id = get_jwt_identity()
result = RemoteDockerService.start_container(server_id, container_id, user_id=user_id)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify({'message': 'Container started'})
@servers_bp.route('/<server_id>/docker/containers/<container_id>/stop', methods=['POST'])
@developer_required
def stop_remote_container(server_id, container_id):
"""Stop a container on a remote server"""
user_id = get_jwt_identity()
data = json_object()
timeout = data.get('timeout')
result = RemoteDockerService.stop_container(server_id, container_id, timeout=timeout, user_id=user_id)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify({'message': 'Container stopped'})
@servers_bp.route('/<server_id>/docker/containers/<container_id>/restart', methods=['POST'])
@developer_required
def restart_remote_container(server_id, container_id):
"""Restart a container on a remote server"""
user_id = get_jwt_identity()
data = json_object()
timeout = data.get('timeout')
result = RemoteDockerService.restart_container(server_id, container_id, timeout=timeout, user_id=user_id)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify({'message': 'Container restarted'})
@servers_bp.route('/<server_id>/docker/containers/<container_id>', methods=['DELETE'])
@developer_required
def remove_remote_container(server_id, container_id):
"""Remove a container on a remote server"""
user_id = get_jwt_identity()
force = request.args.get('force', 'false').lower() == 'true'
remove_volumes = request.args.get('v', 'false').lower() == 'true'
result = RemoteDockerService.remove_container(
server_id, container_id,
force=force, remove_volumes=remove_volumes,
user_id=user_id
)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify({'message': 'Container removed'})
@servers_bp.route('/<server_id>/docker/containers/<container_id>/stats', methods=['GET'])
@jwt_required()
def get_remote_container_stats(server_id, container_id):
"""Get container stats from a remote server"""
user_id = get_jwt_identity()
result = RemoteDockerService.get_container_stats(server_id, container_id, user_id=user_id)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify(result.get('data'))
@servers_bp.route('/<server_id>/docker/containers/<container_id>/logs', methods=['GET'])
@jwt_required()
def get_remote_container_logs(server_id, container_id):
"""
Get container logs from a remote server.
Query params:
tail: Number of lines (default 100, 'all' for all lines)
since: Show logs since timestamp
timestamps: Include timestamps (default true)
"""
user_id = get_jwt_identity()
tail = request.args.get('tail', '100')
since = request.args.get('since')
timestamps = request.args.get('timestamps', 'true').lower() == 'true'
result = RemoteDockerService.get_container_logs(
server_id, container_id,
tail=tail, since=since, timestamps=timestamps,
user_id=user_id
)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify(result.get('data'))
@servers_bp.route('/<server_id>/docker/images', methods=['GET'])
@jwt_required()
def list_remote_images(server_id):
"""List images on a remote server"""
user_id = get_jwt_identity()
result = RemoteDockerService.list_images(server_id, user_id=user_id)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify(result.get('data', []))
@servers_bp.route('/<server_id>/docker/images/pull', methods=['POST'])
@developer_required
def pull_remote_image(server_id):
"""Pull an image on a remote server"""
user_id = get_jwt_identity()
data = json_object()
if not data or not data.get('image'):
return jsonify({'error': 'Image name required'}), 400
result = RemoteDockerService.pull_image(server_id, data['image'], user_id=user_id)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify(result)
@servers_bp.route('/<server_id>/docker/images/<image_id>', methods=['DELETE'])
@developer_required
def remove_remote_image(server_id, image_id):
"""Remove an image on a remote server"""
user_id = get_jwt_identity()
force = request.args.get('force', 'false').lower() == 'true'
result = RemoteDockerService.remove_image(server_id, image_id, force=force, user_id=user_id)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify({'message': 'Image removed'})
@servers_bp.route('/<server_id>/docker/volumes', methods=['GET'])
@jwt_required()
def list_remote_volumes(server_id):
"""List volumes on a remote server"""
user_id = get_jwt_identity()
result = RemoteDockerService.list_volumes(server_id, user_id=user_id)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify(result.get('data', []))
@servers_bp.route('/<server_id>/docker/volumes/<volume_name>', methods=['DELETE'])
@developer_required
def remove_remote_volume(server_id, volume_name):
"""Remove a volume on a remote server"""
user_id = get_jwt_identity()
force = request.args.get('force', 'false').lower() == 'true'
result = RemoteDockerService.remove_volume(server_id, volume_name, force=force, user_id=user_id)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify({'message': 'Volume removed'})
@servers_bp.route('/<server_id>/docker/networks', methods=['GET'])
@jwt_required()
def list_remote_networks(server_id):
"""List networks on a remote server"""
user_id = get_jwt_identity()
result = RemoteDockerService.list_networks(server_id, user_id=user_id)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify(result.get('data', []))
@servers_bp.route('/<server_id>/docker/networks/<network_id>', methods=['DELETE'])
@developer_required
def remove_remote_network(server_id, network_id):
"""Remove a network on a remote server"""
user_id = get_jwt_identity()
result = RemoteDockerService.remove_network(server_id, network_id, user_id=user_id)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify({'message': 'Network removed'})
@servers_bp.route('/<server_id>/system/metrics', methods=['GET'])
@jwt_required()
def get_remote_system_metrics(server_id):
"""Get system metrics from a remote server"""
user_id = get_jwt_identity()
result = RemoteDockerService.get_system_metrics(server_id, user_id=user_id)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify(result.get('data'))
@servers_bp.route('/<server_id>/system/info', methods=['GET'])
@jwt_required()
def get_remote_system_info(server_id):
"""Get system info from a remote server"""
user_id = get_jwt_identity()
result = RemoteDockerService.get_system_info(server_id, user_id=user_id)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify(result.get('data'))
# ==================== Remote Docker Compose Operations ====================
@servers_bp.route('/<server_id>/docker/compose/projects', methods=['GET'])
@jwt_required()
def list_remote_compose_projects(server_id):
"""List compose projects on a remote server"""
user_id = get_jwt_identity()
result = RemoteDockerService.compose_list(server_id, user_id=user_id)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify(result.get('data', []))
@servers_bp.route('/<server_id>/docker/compose/ps', methods=['POST'])
@developer_required
def remote_compose_ps(server_id):
"""List containers for a compose project"""
user_id = get_jwt_identity()
data = json_object()
if not data or not data.get('project_path'):
raise field_required('project_path')
result = RemoteDockerService.compose_ps(
server_id,
data['project_path'],
user_id=user_id
)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify(result.get('data', []))
@servers_bp.route('/<server_id>/docker/compose/up', methods=['POST'])
@developer_required
def remote_compose_up(server_id):
"""Start a compose project"""
user_id = get_jwt_identity()
data = json_object()
if not data or not data.get('project_path'):
raise field_required('project_path')
result = RemoteDockerService.compose_up(
server_id,
data['project_path'],
detach=data.get('detach', True),
build=data.get('build', False),
user_id=user_id
)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify(result)
@servers_bp.route('/<server_id>/docker/compose/down', methods=['POST'])
@developer_required
def remote_compose_down(server_id):
"""Stop a compose project"""
user_id = get_jwt_identity()
data = json_object()
if not data or not data.get('project_path'):
raise field_required('project_path')
result = RemoteDockerService.compose_down(
server_id,
data['project_path'],
volumes=data.get('volumes', False),
remove_orphans=data.get('remove_orphans', True),
user_id=user_id
)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify(result)
@servers_bp.route('/<server_id>/docker/compose/logs', methods=['POST'])
@developer_required
def remote_compose_logs(server_id):
"""Get logs from a compose project"""
user_id = get_jwt_identity()
data = json_object()
if not data or not data.get('project_path'):
raise field_required('project_path')
result = RemoteDockerService.compose_logs(
server_id,
data['project_path'],
service=data.get('service'),
tail=data.get('tail', 100),
user_id=user_id
)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify(result.get('data'))
@servers_bp.route('/<server_id>/docker/compose/restart', methods=['POST'])
@developer_required
def remote_compose_restart(server_id):
"""Restart a compose project or specific service"""
user_id = get_jwt_identity()
data = json_object()
if not data or not data.get('project_path'):
raise field_required('project_path')
result = RemoteDockerService.compose_restart(
server_id,
data['project_path'],
service=data.get('service'),
user_id=user_id
)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify(result)
@servers_bp.route('/<server_id>/docker/compose/pull', methods=['POST'])
@developer_required
def remote_compose_pull(server_id):
"""Pull images for a compose project"""
user_id = get_jwt_identity()
data = json_object()
if not data or not data.get('project_path'):
raise field_required('project_path')
result = RemoteDockerService.compose_pull(
server_id,
data['project_path'],
service=data.get('service'),
user_id=user_id
)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify(result)
+603
View File
@@ -0,0 +1,603 @@
"""Remote terminal, cron, cloudflared, packages, systemd, runtimes and file routes.
Registered on the shared ``servers_bp`` blueprint (see app.api.servers)."""
from app.services.remote_cloudflared_service import RemoteCloudflaredService
from app.services.remote_cron_service import RemoteCronService
from app.services.remote_file_service import RemoteFileService
from app.services.remote_packages_service import RemotePackagesService
from app.services.remote_runtimes_service import RemoteRuntimesService
from app.services.remote_systemd_service import RemoteSystemdService
from app.services.terminal_service import TerminalService
from app.middleware.rbac import developer_required
from app.services.remote_command_dispatcher import dispatch_agent_command
from app.exceptions import field_required
from flask_jwt_extended import get_jwt_identity, jwt_required
from flask import jsonify, request
from app.api.servers import _agent_failure_status, _get_operable_server, servers_bp
from app.api._request import json_object
# ==================== Remote Terminal ====================
@servers_bp.route('/<server_id>/terminal', methods=['POST'])
@developer_required
def create_terminal_session(server_id):
"""Create a new terminal session on a remote server.
Body:
cols: Terminal width (default: 80)
rows: Terminal height (default: 24)
"""
_get_operable_server(server_id)
user_id = get_jwt_identity()
data = json_object()
cols = data.get('cols', 80)
rows = data.get('rows', 24)
result = TerminalService.create_session(
server_id=server_id,
user_id=user_id,
cols=cols,
rows=rows
)
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
return jsonify(result)
@servers_bp.route('/terminal/<session_id>/input', methods=['POST'])
@developer_required
def terminal_input(session_id):
"""Send input to a terminal session.
Body:
data: Base64-encoded input data
"""
user_id = get_jwt_identity()
data = json_object()
if not data or not data.get('data'):
raise field_required('data')
result = TerminalService.send_input(
session_id=session_id,
data=data['data'],
user_id=user_id
)
if not result.get('success'):
return jsonify(result), 400
return jsonify(result)
@servers_bp.route('/terminal/<session_id>/resize', methods=['POST'])
@developer_required
def terminal_resize(session_id):
"""Resize a terminal session.
Body:
cols: New terminal width
rows: New terminal height
"""
user_id = get_jwt_identity()
data = json_object()
if not data or not data.get('cols') or not data.get('rows'):
return jsonify({'error': 'cols and rows are required'}), 400
result = TerminalService.resize_session(
session_id=session_id,
cols=data['cols'],
rows=data['rows'],
user_id=user_id
)
if not result.get('success'):
return jsonify(result), 400
return jsonify(result)
@servers_bp.route('/terminal/<session_id>', methods=['DELETE'])
@developer_required
def close_terminal_session(session_id):
"""Close a terminal session."""
user_id = get_jwt_identity()
result = TerminalService.close_session(
session_id=session_id,
user_id=user_id
)
if not result.get('success'):
return jsonify(result), 400
return jsonify(result)
@servers_bp.route('/terminal/sessions', methods=['GET'])
@jwt_required()
def list_terminal_sessions():
"""List all terminal sessions for the current user."""
user_id = get_jwt_identity()
sessions = TerminalService.get_user_sessions(user_id)
return jsonify({
'sessions': sessions,
'count': len(sessions)
})
# ==================== Remote Cron Operations ====================
#
# Endpoints under /servers/<id>/cron/ proxy to the agent's cron:*
# command handlers. They mirror the panel-local /api/v1/cron/* surface
# (CronService) but target a remote host. The agent owns validation and
# crontab IO; this layer is just transport.
def _agent_result(result, ok_status=200):
"""Translate an agent_registry.send_command result into an HTTP
response: a failure with its status, success unwrapped."""
if not result.get('success'):
return jsonify(result), _agent_failure_status(result)
# Unwrap the agent's data payload so callers see the same shape as
# the local CronService responses.
data = result.get('data')
if isinstance(data, dict):
return jsonify(data), ok_status
return jsonify({'data': data, 'success': True}), ok_status
@servers_bp.route('/<server_id>/cron/status', methods=['GET'])
@jwt_required()
def remote_cron_status(server_id):
user_id = get_jwt_identity()
result = RemoteCronService.status(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/cron/jobs', methods=['GET'])
@jwt_required()
def remote_cron_list(server_id):
user_id = get_jwt_identity()
result = RemoteCronService.list_jobs(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/cron/jobs', methods=['POST'])
@developer_required
def remote_cron_add(server_id):
user_id = get_jwt_identity()
data = json_object(optional=True)
schedule = (data.get('schedule') or '').strip()
command = (data.get('command') or '').strip()
if not schedule:
raise field_required('schedule')
if not command:
raise field_required('command')
result = RemoteCronService.add_job(
server_id, schedule, command,
name=data.get('name'),
description=data.get('description'),
user_id=user_id,
)
return _agent_result(result, ok_status=201)
@servers_bp.route('/<server_id>/cron/jobs/<job_id>', methods=['DELETE'])
@developer_required
def remote_cron_remove(server_id, job_id):
user_id = get_jwt_identity()
result = RemoteCronService.remove_job(server_id, job_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/cron/jobs/<job_id>/toggle', methods=['POST'])
@developer_required
def remote_cron_toggle(server_id, job_id):
user_id = get_jwt_identity()
data = json_object(optional=True)
enabled = bool(data.get('enabled', True))
result = RemoteCronService.toggle_job(server_id, job_id, enabled, user_id=user_id)
return _agent_result(result)
# ==================== Remote Cloudflared Operations ====================
#
# Auth: the user runs `cloudflared tunnel login` once per server.
# That writes ~/.cloudflared/cert.pem (or /etc/cloudflared/cert.pem
# when run as root). The panel never sees a Cloudflare API token —
# every cloudflared:* action just shells out to the binary, which
# uses the cert for auth.
#
# /status surfaces both "binary installed" and "cert present" so the
# UI can show a "log in first" prompt before letting users hit the
# CRUD actions and getting confusing errors.
@servers_bp.route('/<server_id>/cloudflared/status', methods=['GET'])
@jwt_required()
def remote_cloudflared_status(server_id):
user_id = get_jwt_identity()
result = RemoteCloudflaredService.status(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/cloudflared/login', methods=['POST'])
@developer_required
def remote_cloudflared_login(server_id):
"""Start `cloudflared tunnel login` on the agent. Returns
{job_id, channel}; the panel subscribes to the corresponding
Socket.IO room and renders the auth_url the agent surfaces."""
user_id = get_jwt_identity()
result = RemoteCloudflaredService.login(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/cloudflared/tunnels', methods=['GET'])
@jwt_required()
def remote_cloudflared_list(server_id):
user_id = get_jwt_identity()
result = RemoteCloudflaredService.list_tunnels(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/cloudflared/tunnels', methods=['POST'])
@developer_required
def remote_cloudflared_create(server_id):
user_id = get_jwt_identity()
data = json_object(optional=True)
name = (data.get('name') or '').strip()
if not name:
raise field_required('name')
result = RemoteCloudflaredService.create_tunnel(server_id, name, user_id=user_id)
return _agent_result(result, ok_status=201)
@servers_bp.route('/<server_id>/cloudflared/tunnels/<tunnel_ref>/route', methods=['POST'])
@developer_required
def remote_cloudflared_route(server_id, tunnel_ref):
user_id = get_jwt_identity()
data = json_object(optional=True)
hostname = (data.get('hostname') or '').strip()
if not hostname:
raise field_required('hostname')
result = RemoteCloudflaredService.route_tunnel(
server_id, tunnel_ref, hostname, user_id=user_id,
)
return _agent_result(result)
@servers_bp.route('/<server_id>/cloudflared/tunnels/<tunnel_ref>', methods=['DELETE'])
@developer_required
def remote_cloudflared_delete(server_id, tunnel_ref):
user_id = get_jwt_identity()
result = RemoteCloudflaredService.delete_tunnel(server_id, tunnel_ref, user_id=user_id)
return _agent_result(result)
# ==================== Remote Capability Refresh ====================
#
# Lets the panel ask the agent to re-run its capability probe on
# demand — useful after the user installs a runtime, sudoers entry,
# or new package manager and wants new tabs to light up without an
# agent service restart. The agent's response is the freshly merged
# capabilities map; the agent additionally pushes via the persistent
# Capabilities message so all panel listeners stay in sync.
@servers_bp.route('/<server_id>/refresh-capabilities', methods=['POST'])
@developer_required
def remote_refresh_capabilities(server_id):
user_id = get_jwt_identity()
result = dispatch_agent_command(
server_id=server_id, action='agent:recapabilities',
params={}, user_id=user_id, timeout=20.0,
)
return _agent_result(result)
# ==================== Remote Packages ====================
#
# Exposes the agent's packages:* surface for the Packages tab.
# Long-running operations (install/upgrade) return {job_id, channel};
# the frontend subscribes to Socket.IO room
# server_<id>_<channel> for live progress events.
@servers_bp.route('/<server_id>/packages', methods=['GET'])
@jwt_required()
def remote_packages_list(server_id):
user_id = get_jwt_identity()
result = RemotePackagesService.list_installed(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/packages/search', methods=['GET'])
@jwt_required()
def remote_packages_search(server_id):
user_id = get_jwt_identity()
query = (request.args.get('q') or '').strip()
if not query:
raise field_required('q')
try:
limit = int(request.args.get('limit', 100))
except (TypeError, ValueError):
limit = 100
result = RemotePackagesService.search(server_id, query, limit=limit, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/packages/info/<name>', methods=['GET'])
@jwt_required()
def remote_packages_info(server_id, name):
user_id = get_jwt_identity()
result = RemotePackagesService.info(server_id, name, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/packages/update-cache', methods=['POST'])
@developer_required
def remote_packages_update_cache(server_id):
user_id = get_jwt_identity()
result = RemotePackagesService.update_cache(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/packages/install', methods=['POST'])
@developer_required
def remote_packages_install(server_id):
"""Streaming install. Body: {names: ['nginx', 'redis-server']}.
Returns {job_id, channel} immediately; the panel subscribes to the
matching Socket.IO room for live install output."""
user_id = get_jwt_identity()
data = json_object(optional=True)
names = data.get('names') or []
if isinstance(names, str):
names = [names]
if not names:
raise field_required('names')
result = RemotePackagesService.install_async(server_id, names, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/packages/remove', methods=['POST'])
@developer_required
def remote_packages_remove(server_id):
user_id = get_jwt_identity()
data = json_object(optional=True)
name = (data.get('name') or '').strip()
if not name:
raise field_required('name')
result = RemotePackagesService.remove(server_id, name, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/packages/upgrade', methods=['POST'])
@developer_required
def remote_packages_upgrade(server_id):
"""Streaming upgrade. Body: {all: bool, names?: [...]}. Returns
{job_id, channel}."""
user_id = get_jwt_identity()
data = json_object(optional=True)
names = data.get('names') or []
all_pkgs = bool(data.get('all', False))
if not all_pkgs and not names:
return jsonify({'error': 'either all=true or names=[...] required'}), 400
result = RemotePackagesService.upgrade(
server_id,
names=names if names else None,
all_packages=all_pkgs,
user_id=user_id,
)
return _agent_result(result)
# ==================== Remote Services (systemd) ====================
@servers_bp.route('/<server_id>/services', methods=['GET'])
@jwt_required()
def remote_services_list(server_id):
user_id = get_jwt_identity()
state = request.args.get('state')
type_ = request.args.get('type', 'service')
result = RemoteSystemdService.list_units(server_id, state=state, type_=type_, user_id=user_id)
return _agent_result(result)
# Static-suffix routes must come before the generic <unit>/<action>
# route so Werkzeug picks the right matcher in registration order.
@servers_bp.route('/<server_id>/services/daemon-reload', methods=['POST'])
@developer_required
def remote_services_daemon_reload(server_id):
user_id = get_jwt_identity()
result = RemoteSystemdService.daemon_reload(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/services/<unit>/logs', methods=['GET'])
@jwt_required()
def remote_services_logs(server_id, unit):
user_id = get_jwt_identity()
try:
lines = int(request.args.get('lines', 200))
except (TypeError, ValueError):
lines = 200
result = RemoteSystemdService.logs(server_id, unit, lines=lines, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/services/<unit>', methods=['GET'])
@jwt_required()
def remote_services_status(server_id, unit):
user_id = get_jwt_identity()
result = RemoteSystemdService.status(server_id, unit, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/services/<unit>/<action>', methods=['POST'])
@developer_required
def remote_services_control(server_id, unit, action):
user_id = get_jwt_identity()
result = RemoteSystemdService.control(server_id, unit, action, user_id=user_id)
return _agent_result(result)
# ==================== Remote Runtimes (pyenv) ====================
#
# Manages Python versions via pyenv (Linux) and pyenv-win (Windows).
# Bootstrap and install return {job_id, channel} for streaming
# progress; everything else is a synchronous round-trip.
@servers_bp.route('/<server_id>/runtimes', methods=['GET'])
@jwt_required()
def remote_runtimes_list(server_id):
user_id = get_jwt_identity()
result = RemoteRuntimesService.list_state(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/runtimes/pyenv/bootstrap', methods=['POST'])
@developer_required
def remote_runtimes_pyenv_bootstrap(server_id):
user_id = get_jwt_identity()
result = RemoteRuntimesService.pyenv_bootstrap(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/runtimes/python', methods=['GET'])
@jwt_required()
def remote_runtimes_python_installed(server_id):
user_id = get_jwt_identity()
result = RemoteRuntimesService.python_installed(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/runtimes/python/available', methods=['GET'])
@jwt_required()
def remote_runtimes_python_available(server_id):
user_id = get_jwt_identity()
result = RemoteRuntimesService.python_available(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/runtimes/python/current', methods=['GET'])
@jwt_required()
def remote_runtimes_python_current(server_id):
user_id = get_jwt_identity()
result = RemoteRuntimesService.python_current(server_id, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/runtimes/python/install', methods=['POST'])
@developer_required
def remote_runtimes_python_install(server_id):
user_id = get_jwt_identity()
data = json_object(optional=True)
version = (data.get('version') or '').strip()
if not version:
raise field_required('version')
result = RemoteRuntimesService.python_install(server_id, version, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/runtimes/python/uninstall', methods=['POST'])
@developer_required
def remote_runtimes_python_uninstall(server_id):
user_id = get_jwt_identity()
data = json_object(optional=True)
version = (data.get('version') or '').strip()
if not version:
raise field_required('version')
result = RemoteRuntimesService.python_uninstall(server_id, version, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/runtimes/python/global', methods=['POST'])
@developer_required
def remote_runtimes_python_set_global(server_id):
user_id = get_jwt_identity()
data = json_object(optional=True)
version = (data.get('version') or '').strip()
if not version:
raise field_required('version')
result = RemoteRuntimesService.python_set_global(server_id, version, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/runtimes/python/local', methods=['POST'])
@developer_required
def remote_runtimes_python_set_local(server_id):
user_id = get_jwt_identity()
data = json_object(optional=True)
version = (data.get('version') or '').strip()
dir_path = (data.get('dir') or '').strip()
if not version or not dir_path:
return jsonify({'error': 'version and dir are required'}), 400
result = RemoteRuntimesService.python_set_local(server_id, version, dir_path, user_id=user_id)
return _agent_result(result)
# ==================== Remote File Operations ====================
#
# Endpoints under /servers/<id>/files/ proxy to the agent's file:*
# command handlers. Phase 3b ships browse/read/write only — the broader
# verb set (delete, mkdir, rename, copy, chmod, search, disk_usage)
# requires new agent handlers and lands in a follow-up. The agent owns
# allowed_paths enforcement; the panel just transports.
@servers_bp.route('/<server_id>/files/allowed-paths', methods=['GET'])
@jwt_required()
def remote_file_allowed_paths(server_id):
"""Return the file roots the agent advertised on connect. Lets the
file manager seed the browse picker without guessing or hardcoding."""
paths = RemoteFileService.get_allowed_paths(server_id)
return jsonify({'allowed_paths': paths})
@servers_bp.route('/<server_id>/files/browse', methods=['GET'])
@jwt_required()
def remote_file_browse(server_id):
user_id = get_jwt_identity()
path = request.args.get('path')
if not path:
raise field_required('path')
result = RemoteFileService.list_directory(server_id, path, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/files/read', methods=['GET'])
@jwt_required()
def remote_file_read(server_id):
user_id = get_jwt_identity()
path = request.args.get('path')
if not path:
raise field_required('path')
result = RemoteFileService.read_file(server_id, path, user_id=user_id)
return _agent_result(result)
@servers_bp.route('/<server_id>/files/write', methods=['POST'])
@developer_required
def remote_file_write(server_id):
user_id = get_jwt_identity()
data = json_object(optional=True)
path = (data.get('path') or '').strip()
content = data.get('content')
if not path:
raise field_required('path')
if content is None:
raise field_required('content')
result = RemoteFileService.write_file(server_id, path, content, user_id=user_id)
return _agent_result(result)
+18 -42
View File
@@ -13,7 +13,6 @@ ref}``). The doctor report carries the same items as its ``setup.*`` section, so
this endpoint and Monitoring → Doctor stay in lockstep.
"""
from flask import Blueprint, jsonify, request
from app.error_reporting import unexpected_response
from ..middleware.rbac import admin_required, auth_required, get_current_user
from ..services.setup_health_service import SetupHealthService
@@ -22,6 +21,7 @@ from ..services.setup_reconcile_service import (
URL_FIX_JOB_KIND,
SetupReconcileService,
)
from app.api._request import json_object
setup_health_bp = Blueprint('setup_health', __name__)
@@ -32,10 +32,7 @@ setup_health_bp = Blueprint('setup_health', __name__)
def get_setup_health():
"""Evaluate the setup-health registry (panel scope) and return items +
summary. Cheap — DB/settings probes only, no host calls."""
try:
return jsonify(SetupHealthService.evaluate(scope='panel'))
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)
return jsonify(SetupHealthService.evaluate(scope='panel'))
@setup_health_bp.route('/account', methods=['GET'])
@@ -43,13 +40,10 @@ def get_setup_health():
def get_account_security():
"""The requesting user's own setup-health items (scope 'user') — e.g. the
'secure your account' nudge. Any authenticated user, about themselves."""
try:
user = get_current_user()
if not user:
return jsonify({'error': 'User not found'}), 404
return jsonify(SetupHealthService.evaluate(scope='user', user=user))
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)
user = get_current_user()
if not user:
return jsonify({'error': 'User not found'}), 404
return jsonify(SetupHealthService.evaluate(scope='user', user=user))
# --------------------------------------------------------------------------- #
@@ -75,34 +69,28 @@ def _snooze_guard(key):
@auth_required()
def snooze_item():
"""Snooze a setup item (mutes it; it still renders, just quietly)."""
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
key = data.get('key')
if not key:
return jsonify({'error': "Body must carry an item 'key'."}), 400
user, err = _snooze_guard(key)
if err:
return err
try:
return jsonify(SetupHealthService.snooze(key, days=data.get('days', 30), user=user))
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)
return jsonify(SetupHealthService.snooze(key, days=data.get('days', 30), user=user))
@setup_health_bp.route('/snooze', methods=['DELETE'])
@auth_required()
def unsnooze_item():
"""Clear a snooze so the item is active again."""
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
key = data.get('key') or request.args.get('key')
if not key:
return jsonify({'error': "An item 'key' is required."}), 400
user, err = _snooze_guard(key)
if err:
return err
try:
return jsonify(SetupHealthService.unsnooze(key, user=user))
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)
return jsonify(SetupHealthService.unsnooze(key, user=user))
# --------------------------------------------------------------------------- #
@@ -114,22 +102,16 @@ def unsnooze_item():
@admin_required
def dns_backfill_preview():
"""Dry-run: the managed hosts a DNS backfill would ensure records for."""
try:
return jsonify(SetupReconcileService.dns_backfill_preview())
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)
return jsonify(SetupReconcileService.dns_backfill_preview())
@setup_health_bp.route('/reconcile/dns/apply', methods=['POST'])
@admin_required
def dns_backfill_apply():
"""Enqueue the DNS backfill job (loops ensure_a_record, ownership-guarded)."""
try:
from app.jobs.service import JobService
job = JobService.enqueue(DNS_BACKFILL_JOB_KIND, payload={}, max_attempts=1)
return jsonify({'job_id': job.id}), 202
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)
from app.jobs.service import JobService
job = JobService.enqueue(DNS_BACKFILL_JOB_KIND, payload={}, max_attempts=1)
return jsonify({'job_id': job.id}), 202
@setup_health_bp.route('/reconcile/url-fix/preview', methods=['POST'])
@@ -137,19 +119,13 @@ def dns_backfill_apply():
def url_fix_preview():
"""Dry-run: WordPress sites whose siteurl/home still point at localhost or a
stale IP, with the per-site swap they'd get."""
try:
return jsonify(SetupReconcileService.url_fix_preview())
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)
return jsonify(SetupReconcileService.url_fix_preview())
@setup_health_bp.route('/reconcile/url-fix/apply', methods=['POST'])
@admin_required
def url_fix_apply():
"""Enqueue the WordPress URL-fix job (loops change_site_url)."""
try:
from app.jobs.service import JobService
job = JobService.enqueue(URL_FIX_JOB_KIND, payload={}, max_attempts=1)
return jsonify({'job_id': job.id}), 202
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)
from app.jobs.service import JobService
job = JobService.enqueue(URL_FIX_JOB_KIND, payload={}, max_attempts=1)
return jsonify({'job_id': job.id}), 202
+9 -8
View File
@@ -15,6 +15,7 @@ from app.services.resource_grant_service import ResourceGrantService
from app.services.shared_resource_service import SharedResourceService
from app.models.shared_resource import SharedVariableGroup
from app.utils.sensitive_data_filter import mask_sensitive
from app.api._request import json_object
shared_resources_bp = Blueprint('shared_resources', __name__)
@@ -204,7 +205,7 @@ def list_tags():
@shared_resources_bp.route('/tags', methods=['POST'])
@jwt_required()
def add_tag():
data = request.get_json() or {}
data = json_object()
missing = _require(data, 'resource_type', 'resource_id', 'tag')
if missing:
return _bad(f'{missing} is required')
@@ -224,7 +225,7 @@ def add_tag():
@jwt_required()
def remove_tag():
# Accept either body or query params for convenience.
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
resource_type = data.get('resource_type') or request.args.get('resource_type')
resource_id = data.get('resource_id')
if resource_id is None:
@@ -268,7 +269,7 @@ def list_groups():
@shared_resources_bp.route('/variable-groups', methods=['POST'])
@jwt_required()
def create_group():
data = request.get_json() or {}
data = json_object()
missing = _require(data, 'scope_type', 'scope_id', 'name')
if missing:
return _bad(f'{missing} is required')
@@ -302,7 +303,7 @@ def update_group(group_id):
group, denied = _get_scoped_group(group_id, write=True)
if denied:
return denied
data = request.get_json() or {}
data = json_object()
try:
group = SharedResourceService.update_group(
group.id, name=data.get('name'), description=data.get('description')
@@ -333,7 +334,7 @@ def add_variable(group_id):
group, denied = _get_scoped_group(group_id, write=True)
if denied:
return denied
data = request.get_json() or {}
data = json_object()
if data.get('key') in (None, ''):
return _bad('key is required')
try:
@@ -360,7 +361,7 @@ def update_variable(group_id, variable_id):
existing = SharedVariable.query.get(variable_id)
if not existing or existing.group_id != group.id:
return _bad('variable not found', 404)
data = request.get_json() or {}
data = json_object()
_kwargs = {'value': data.get('value'), 'is_secret': data.get('is_secret')}
if 'target_service' in data:
# Only forward when the client sent it, so it's preserved otherwise.
@@ -391,7 +392,7 @@ def delete_variable(group_id, variable_id):
@shared_resources_bp.route('/variable-groups/<int:group_id>/attach', methods=['POST'])
@jwt_required()
def attach_group(group_id):
data = request.get_json() or {}
data = json_object()
missing = _require(data, 'resource_type', 'resource_id')
if missing:
return _bad(f'{missing} is required')
@@ -412,7 +413,7 @@ def attach_group(group_id):
@shared_resources_bp.route('/variable-groups/<int:group_id>/detach', methods=['POST'])
@jwt_required()
def detach_group(group_id):
data = request.get_json() or {}
data = json_object()
missing = _require(data, 'resource_type', 'resource_id')
if missing:
return _bad(f'{missing} is required')
+5 -4
View File
@@ -8,6 +8,7 @@ from flask import Blueprint, jsonify, request
from app.middleware.rbac import admin_required, get_current_user
from app.services.site_import_service import SiteImportError, SiteImportService
from app.api._request import json_object
site_imports_bp = Blueprint('site_imports', __name__)
@@ -33,9 +34,9 @@ def upload_archive():
@admin_required
def probe_ssh_source():
"""Preflight an SSH import source: test the connection, list the docroot,
sniff the stack (plan 31 #8). Linux-only runtime — returns 501 on a host
sniff the stack. Linux-only runtime — returns 501 on a host
that can't run ssh (e.g. Windows dev) so the wizard can message it."""
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
source = data.get('source') or data
try:
result = SiteImportService.probe_ssh(source)
@@ -49,7 +50,7 @@ def probe_ssh_source():
@site_imports_bp.route('', methods=['POST'])
@admin_required
def create_import():
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
user = get_current_user()
try:
imp = SiteImportService.create(
@@ -100,7 +101,7 @@ def run_import(import_id):
if imp.status not in ('analyzed', 'failed'):
return jsonify({'error': "Import must be analysed before it can run "
f"(status: {imp.status})"}), 409
data = request.get_json(silent=True) or {}
data = json_object(optional=True)
from_step = data.get('from_step') or None
# Merge wizard-provided run options into the stored ones (body wins),
# e.g. {'skip_db': True, 'skip_crontab': True}.
+8 -32
View File
@@ -1,4 +1,4 @@
# Bucket: PER-APP (plan 29 #9). Snapshot reads (list/get/diff) require app
# Bucket: PER-APP. Snapshot reads (list/get/diff) require app
# access (can_access_app); restore requires write access (can_edit_app) on top
# of the panel-wide @developer_required.
"""
@@ -14,30 +14,14 @@ from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.middleware.rbac import developer_required, get_current_user
from app.models.application import Application
from app.models.deployment_snapshot import DeploymentSnapshot
from app.services.configuration_service import ConfigurationService
from app.services.resource_grant_service import ResourceGrantService
from app.exceptions import not_found, permission_denied
from app.exceptions import not_found
from app.api._access import require_app
snapshots_bp = Blueprint('snapshots', __name__)
def _get_app_or_404(app_id, write=False):
"""Resolve the app and enforce per-app access (plan 29 #9). ``write`` requires
can_edit_app (owner/admin/editor); otherwise can_access_app. A caller without
the needed tier gets a 403 (the resource itself exists)."""
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
user = get_current_user()
ok = (ResourceGrantService.can_edit_app(user, app) if write
else ResourceGrantService.can_access_app(user, app))
if not ok:
raise permission_denied()
return app, None
def _get_snapshot_or_404(app_id, snap_id):
"""Return (snapshot, None) or (None, error_response). Scoped to the app."""
snap = DeploymentSnapshot.query.filter_by(
@@ -49,16 +33,14 @@ def _get_snapshot_or_404(app_id, snap_id):
# "Config Checkpoint" is the user-facing name for a deployment config snapshot
# (§8 decision: rename in the UI; keep /snapshots working). Every route below is
# (decision: rename in the UI; keep /snapshots working). Every route below is
# also reachable under /config-checkpoints so the API matches the new wording.
@snapshots_bp.route('/<int:app_id>/snapshots', methods=['GET'])
@snapshots_bp.route('/<int:app_id>/config-checkpoints', methods=['GET'])
@jwt_required()
def list_snapshots(app_id):
"""List config snapshots for an app, newest first."""
app, err = _get_app_or_404(app_id)
if err:
return err
app = require_app(app_id)
try:
limit = min(int(request.args.get('limit', 50)), 200)
@@ -82,9 +64,7 @@ def list_snapshots(app_id):
@jwt_required()
def get_snapshot(app_id, snap_id):
"""Fetch a single snapshot, including its resolved config."""
_, err = _get_app_or_404(app_id)
if err:
return err
require_app(app_id)
snap, err = _get_snapshot_or_404(app_id, snap_id)
if err:
return err
@@ -100,9 +80,7 @@ def diff_snapshot(app_id, snap_id):
Query param ``against`` accepts another snapshot id or the literal
``previous`` (default).
"""
_, err = _get_app_or_404(app_id)
if err:
return err
require_app(app_id)
snap, err = _get_snapshot_or_404(app_id, snap_id)
if err:
return err
@@ -145,9 +123,7 @@ def diff_snapshot(app_id, snap_id):
@developer_required
def restore_snapshot(app_id, snap_id):
"""Restore a snapshot's config (env/domains) and trigger a redeploy."""
_, err = _get_app_or_404(app_id, write=True)
if err:
return err
require_app(app_id, write=True)
snap, err = _get_snapshot_or_404(app_id, snap_id)
if err:
return err
+8 -7
View File
@@ -6,6 +6,7 @@ from requests import HTTPError
from app.middleware.rbac import admin_required, get_current_user
from app.services.source_connection_service import SourceConnectionService
from app.api._request import json_object
source_connections_bp = Blueprint('source_connections', __name__)
@@ -35,7 +36,7 @@ def github_authorize():
@jwt_required()
def github_callback():
user_id = int(get_jwt_identity())
data = request.get_json() or {}
data = json_object()
code = data.get('code', '')
state = data.get('state', '')
redirect_uri = data.get('redirect_uri', '')
@@ -125,7 +126,7 @@ def github_admin_config():
def update_github_admin_config():
user = get_current_user()
result = SourceConnectionService.update_github_config(
request.get_json() or {},
json_object(),
user_id=user.id if user else None,
)
return jsonify(result), 200
@@ -150,7 +151,7 @@ def github_app_manifest():
def complete_github_app_manifest():
"""Convert the manifest code GitHub returned into a stored GitHub App."""
user = get_current_user()
data = request.get_json() or {}
data = json_object()
try:
result = SourceConnectionService.complete_github_app_manifest(
code=data.get('code', ''),
@@ -200,7 +201,7 @@ def gitlab_authorize():
@jwt_required()
def gitlab_callback():
user_id = int(get_jwt_identity())
data = request.get_json() or {}
data = json_object()
code = data.get('code', '')
state = data.get('state', '')
redirect_uri = data.get('redirect_uri', '')
@@ -290,7 +291,7 @@ def gitlab_admin_config():
def update_gitlab_admin_config():
user = get_current_user()
result = SourceConnectionService.update_gitlab_config(
request.get_json() or {},
json_object(),
user_id=user.id if user else None,
)
return jsonify(result), 200
@@ -332,7 +333,7 @@ def bitbucket_authorize():
@jwt_required()
def bitbucket_callback():
user_id = int(get_jwt_identity())
data = request.get_json() or {}
data = json_object()
code = data.get('code', '')
state = data.get('state', '')
redirect_uri = data.get('redirect_uri', '')
@@ -422,7 +423,7 @@ def bitbucket_admin_config():
def update_bitbucket_admin_config():
user = get_current_user()
result = SourceConnectionService.update_bitbucket_config(
request.get_json() or {},
json_object(),
user_id=user.id if user else None,
)
return jsonify(result), 200
Loaded 100 of 730 files, more files were not shown because too many files have changed in this diff. Show more