mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-23 03:27:43 +09:00
Merge pull request #164 from jhd3197/dev
Harden, fix and clean up the panel after a full code audit
This commit is contained in:
730 files changed
+10428
-11203
No files matched your search
@@ -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: ["*"]
|
||||
@@ -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
|
||||
|
||||
@@ -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/**'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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/**'
|
||||
|
||||
@@ -12,7 +12,7 @@ on:
|
||||
- 'scripts/security/**'
|
||||
- '.github/workflows/security-scan.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'builtin-extensions/**'
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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,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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']))
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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')
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,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'),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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)})
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,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,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,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
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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,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
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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')
|
||||
|
||||
@@ -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}.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in new issue
Block a user