mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 00:27:11 +09:00
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.
187 lines
7.4 KiB
Python
187 lines
7.4 KiB
Python
"""Recipe run API.
|
|
|
|
Starts runs from three manifest sources — a registry slug (the curated
|
|
``serverkit-recipes`` catalog), reviewed inline YAML/JSON, or a manifest
|
|
already stored on the project — creates a normal DeploymentJob, and resumes
|
|
encrypted human handoffs on that same run. Runs target either an explicit
|
|
``project_id`` or a ``server_id``, for which a per-workspace Recipes project
|
|
is found or created so a catalog install is one call.
|
|
"""
|
|
|
|
from flask import Blueprint, jsonify, request
|
|
|
|
from app import db
|
|
from app.exceptions import NotFoundError, ValidationError
|
|
from app.middleware.rbac import admin_required, auth_required, get_current_user
|
|
from app.models.application_manifest import STATUS_ERROR, STATUS_PENDING
|
|
from app.models.deployment_job import DeploymentJob
|
|
from app.models.project import Project
|
|
from app.services import recipe_registry_service
|
|
from app.services.manifest_persistence_service import ManifestPersistenceService
|
|
from app.services.manifest_spec_service import ManifestError, ManifestSpecService
|
|
from app.services.recipe_execution_service import (
|
|
RECIPE_JOB_KIND,
|
|
RecipeExecutionService,
|
|
)
|
|
from app.api._request import json_object
|
|
|
|
|
|
recipes_bp = Blueprint('recipes', __name__)
|
|
|
|
def _normalized_recipe(data, project_id):
|
|
"""Resolve the manifest source: registry slug > inline content > stored."""
|
|
if data.get('registry_slug'):
|
|
text = recipe_registry_service.get_manifest_text(data['registry_slug'])
|
|
return ManifestSpecService.normalize_text(text), text
|
|
if 'content' in data:
|
|
return ManifestSpecService.normalize_text(data['content']), data['content']
|
|
if 'manifest' in data:
|
|
return ManifestSpecService.normalize(data['manifest']), None
|
|
from app.models.application_manifest import ApplicationManifest
|
|
row = ApplicationManifest.query.filter_by(project_id=project_id).first()
|
|
if row and row.get_normalized():
|
|
return row.get_normalized(), row.raw_text
|
|
raise ManifestError(['Provide `registry_slug`, `content`, `manifest`, or store one first'])
|
|
|
|
|
|
def _validated_params(normalized, raw_params):
|
|
"""Check up-front params against the manifest's declared inputs. Only
|
|
non-secret inputs may be preset; secrets must use the mid-run handoff."""
|
|
if raw_params in (None, {}):
|
|
return {}
|
|
if not isinstance(raw_params, dict):
|
|
raise ValueError('params must be an object of input keys')
|
|
inputs = {
|
|
(step.get('input') or {}).get('key'): (step.get('input') or {})
|
|
for step in (normalized.get('configure') or [])
|
|
if step.get('input')
|
|
}
|
|
unknown = [k for k in raw_params if k not in inputs]
|
|
if unknown:
|
|
raise ValueError(f"Unknown recipe input(s): {', '.join(sorted(unknown))}")
|
|
secrets = [k for k, v in raw_params.items() if v is not None and inputs[k].get('secret')]
|
|
if secrets:
|
|
raise ValueError(
|
|
f"{', '.join(sorted(secrets))} is collected securely during the run "
|
|
"and cannot be preset")
|
|
return {k: str(v) for k, v in raw_params.items() if v is not None}
|
|
|
|
|
|
@recipes_bp.route('/registry', methods=['GET'])
|
|
@auth_required()
|
|
def list_recipe_registry():
|
|
return jsonify({
|
|
'recipes': recipe_registry_service.list_catalog(),
|
|
'source': recipe_registry_service.registry_source_label(),
|
|
}), 200
|
|
|
|
|
|
@recipes_bp.route('/registry/<slug>', methods=['GET'])
|
|
@auth_required()
|
|
def get_recipe_registry_entry(slug):
|
|
entry = recipe_registry_service.get_entry(slug)
|
|
if entry is None:
|
|
raise NotFoundError('Recipe not found in the registry')
|
|
manifest_text = recipe_registry_service.get_manifest_text(slug)
|
|
public = {k: v for k, v in entry.items() if k != '_manifest_url'}
|
|
return jsonify({'recipe': public, 'manifest': manifest_text}), 200
|
|
|
|
|
|
@recipes_bp.route('/runs', methods=['POST'])
|
|
@admin_required
|
|
def start_recipe_run():
|
|
data = json_object(optional=True)
|
|
|
|
project = None
|
|
server = None
|
|
if data.get('server_id'):
|
|
server = RecipeExecutionService.get_server(data.get('server_id'))
|
|
if data.get('project_id'):
|
|
project = Project.query.get(data.get('project_id'))
|
|
if not project:
|
|
return jsonify({'error': 'A valid project_id is required'}), 400
|
|
if project is None and server is None:
|
|
raise ValidationError('Provide server_id or project_id')
|
|
|
|
try:
|
|
normalized, raw = _normalized_recipe(data, project.id if project else None)
|
|
params = _validated_params(normalized, data.get('params'))
|
|
except ManifestError as exc:
|
|
return jsonify({'error': 'Invalid Recipe manifest', 'errors': exc.errors}), 400
|
|
except (ValueError, NotFoundError) as exc:
|
|
raise ValidationError(str(exc)) from exc
|
|
|
|
# A server-targeted catalog run pins the manifest's default server ref.
|
|
if server is not None:
|
|
normalized['server'] = server.name
|
|
if project is None:
|
|
project = RecipeExecutionService.get_or_create_project(server)
|
|
|
|
unsupported = RecipeExecutionService.unsupported_capabilities(normalized)
|
|
if unsupported:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'This panel does not support every Recipe capability',
|
|
'unsupported_capabilities': unsupported,
|
|
}), 400
|
|
|
|
row = ManifestPersistenceService.store_manifest(
|
|
project_id=project.id, normalized=normalized, raw_text=raw,
|
|
status=STATUS_PENDING)
|
|
db.session.commit()
|
|
|
|
user = get_current_user()
|
|
wait = request.args.get('wait', 'false').lower() == 'true'
|
|
result = RecipeExecutionService.start(
|
|
project, normalized, user_id=user.id,
|
|
slug=data.get('slug') or data.get('registry_slug'),
|
|
title=data.get('title'),
|
|
manifest_row=row, params=params, wait=wait)
|
|
if not result.get('success'):
|
|
row.status = STATUS_ERROR
|
|
row.last_error = result.get('error')
|
|
db.session.commit()
|
|
return jsonify(result), 400
|
|
|
|
_audit('recipe.start', user.id, result.get('job_id'), {
|
|
'project_id': project.id, 'slug': data.get('slug') or data.get('registry_slug'),
|
|
'server_id': server.id if server else None,
|
|
})
|
|
return jsonify(result), 200 if wait else 202
|
|
|
|
|
|
@recipes_bp.route('/runs/<job_id>', methods=['GET'])
|
|
@admin_required
|
|
def get_recipe_run(job_id):
|
|
job = DeploymentJob.query.get(job_id)
|
|
if not job or job.kind != RECIPE_JOB_KIND:
|
|
return jsonify({'error': 'Recipe run not found'}), 404
|
|
return jsonify({'job': job.to_dict(include_plan=True, include_logs=True)}), 200
|
|
|
|
|
|
@recipes_bp.route('/runs/<job_id>/handoffs/<step_id>', methods=['POST'])
|
|
@admin_required
|
|
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 = json_object(optional=True)
|
|
user = get_current_user()
|
|
wait = request.args.get('wait', 'false').lower() == 'true'
|
|
result = RecipeExecutionService.submit_handoff(
|
|
job, step_id=step_id, value=data.get('value'), user_id=user.id, wait=wait)
|
|
if not result.get('success'):
|
|
return jsonify(result), 400
|
|
_audit('recipe.handoff.submit', user.id, job.id, {'step_id': step_id})
|
|
return jsonify(result), 200 if wait else 202
|
|
|
|
|
|
def _audit(action, user_id, job_id, details):
|
|
try:
|
|
from app.services.audit_service import AuditService
|
|
AuditService.log(
|
|
action, user_id=user_id, target_type='deployment_job',
|
|
target_id=job_id, details=details)
|
|
except Exception:
|
|
pass
|