Files
ServerKit/backend/app/api/doctor.py
T
Juan Denis 55d8fb1e44 Read route bodies through json_object()
328 more routes used a JSON body as a dict straight from
request.get_json(), so a body of null, [] or a bare string crashed them
into a 500. Bare reads become json_object(); the
'get_json(silent=True) or {}' reads of action routes become
json_object(optional=True), which still treats a missing body as {}.
Routes that take arrays or pass the body through untouched are left
alone. The full backend suite shows no new failures.

Also carries three files' bare section citations (apps, backups,
notifications) and the extended citation guard, which needed them.
2026-10-10 20:53:43 -04:00

102 lines
3.9 KiB
Python

"""REST surface for the doctor sweep + configuration drift.
Mounted at /api/v1/doctor (registered in app/__init__.py). Admin-only.
Contract (the CLI codes against this too):
GET /drift -> {'report': <last drift report>|null}
POST /drift/check -> 202 {'job_id'}
POST /drift/<type>/<id>/repair -> repair result; body must carry
{'confirm': true} (400 otherwise)
GET / -> {'report': <last doctor report>|null}
POST /run -> runs synchronously, {'report': ...}
POST /repair -> {'results': [...]} for body
{'items': [{kind, type?, id?, name?}]}
"""
from flask import Blueprint, jsonify, request
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__)
# --------------------------------------------------------------------------- #
# Drift
# --------------------------------------------------------------------------- #
@doctor_bp.route('/drift', methods=['GET'])
@admin_required
def get_drift_report():
"""Last stored drift report (null when no sweep has run yet)."""
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."""
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 = json_object(optional=True)
if data.get('confirm') is not True:
return jsonify({'error': 'Confirmation required: pass {"confirm": true}.'}), 400
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
# --------------------------------------------------------------------------- #
# Doctor
# --------------------------------------------------------------------------- #
@doctor_bp.route('', methods=['GET'])
@doctor_bp.route('/', methods=['GET'])
@admin_required
def get_doctor_report():
"""Last stored doctor report (null when the doctor has never run)."""
return jsonify({'report': DoctorService.get_last_report()})
@doctor_bp.route('/run', methods=['POST'])
@admin_required
def run_doctor():
"""Run the sweep synchronously (the doctor is interactive; every internal
probe is time-capped) and return the fresh report."""
report = DoctorService.run()
return jsonify({'report': report})
@doctor_bp.route('/repair', methods=['POST'])
@admin_required
def repair_items():
"""Batch-repair selected items, optionally as a persisted operation.
Synchronous remains the default for existing CLI callers. The web shell
asks for ``wait=false`` and follows the returned job in Operations.
"""
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
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)})