Files
ServerKit/backend/app/api/python.py
T
Juan DenisandClaude Opus 5 5f8a3ef204 feat(recycle-bin): applications are soft-deletable, and a delete stops keeping data hostage
Plan 70. Domain went through this and TEN read paths were wrong, four with
effects outside the panel. Application has 211 call sites across 68 files and
drives more -- container lifecycles, nginx, backups, registry pulls, the OS
crontab -- so it was audited in full first (docs/plans/70_...md). 180 sites are
now query_active(); the 35 left unfiltered are each commented in place.

**The delete / purge split is what makes the tombstone honest.** delete_app used
to run compose_down(volumes=True) and rmtree(). Keeping a tombstone AND doing
that would put a Restore button next to a row whose data the delete had already
destroyed -- the one promise a recycle bin cannot break. So:

  DELETE   containers down (VOLUMES KEPT), vhost removed, firewall closed,
           cron suspended, row tombstoned. All reversible.
  RESTORE  vhost rewritten, firewall reopened, cron resumed. Containers left
           STOPPED -- "put it back" must not silently start processes or open
           ports, the same reasoning that stops domain restore re-issuing certs.
  PURGE    volumes removed, uploaded source deleted, cron detached.

register() gained on_purge for that, wired into BOTH purge paths. And
purge_expired had NO scheduler -- fine when a tombstone was just a row, not fine
when it holds data volumes and a source tree, so a daily builtin job now runs
it. Without that, "kept until purge" would have meant kept forever and delete
would have quietly stopped reclaiming disk.

A side effect worth naming: the old _remove_data_flag protection for database
engines is now the DEFAULT for every app, and the flag is carried into
?purge=true so an explicit skip-the-bin delete still honours it.

**Cron was the blocker with no mechanical answer.** before_delete never fires on
a soft delete, and clear_application is LOSSY -- it nulls the association
without recording it, so nothing could put it back. suspend_for_application
instead disables only the jobs that were CURRENTLY ENABLED and stamps each
suspended_by_app; resume re-enables exactly those. That flag is the whole point:
without it a restore switches back on jobs the user deliberately turned off.
clear_application survives as purge-only, where lossiness is correct.

**The indirect reach (audit 3.1) is the class that grep cannot find.**
ContainerSleepPolicy, BackupPolicy, DeploymentJob and WafPolicy are enumerated
on a SCHEDULE and resolve application_id afterwards, so they never appear in a
search for Application.query. Left alone, a tombstoned app's sleep policy is
swept forever and wake_app restarts the containers the delete tore down. Those
loops test Application.deleted_ids().

Deliberately NOT scoped: reservation checks (port, name, directory) -- a
tombstone must keep holding those or a new app takes them and the restore is
refused; api/git.py's webhook VISIBILITY gate, which falls through to
"panel-wide visible" so hiding a tombstone would WIDEN access; and cron
attribution, which only labels a row.

Verified: 15 new tests, the migration replayed on a fresh DB (a tombstone frees
its private_slug, two live rows still collide), and a full delete -> restore
round trip. The 20 remaining full-suite failures are the known
serverkit-wordpress-not-installed class and are identical on a clean tree.

Two traps caught by testing, both recorded in the plan: a bad import made the
ENTIRE purge cleanup silently no-op while purge() still reported success
(_run_purge_hook swallows by design, so assert on the warning, not just ok), and
private_slug had to lose unique=True at the MODEL level or a models-built
database burns the slug regardless of the migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:26:04 -04:00

463 lines
14 KiB
Python

# Bucket: PER-APP (plan 29 #9). 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_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
python_bp = Blueprint('python', __name__)
def get_app_or_404(app_id, current_user_id):
"""Get application and verify read access. Read endpoints rely on this gate;
mutating endpoints additionally carry @admin_required, so honoring per-resource
grants here (#33) safely opens only the read GETs to grantees. Uses can_access_app
(user.id), which also fixes the int-vs-str identity comparison."""
from app.services.resource_grant_service import ResourceGrantService
user = User.query.get(current_user_id)
app = Application.query_active().filter_by(id=app_id).first()
if not app:
return None, ({'error': 'Application not found'}, 404)
if not ResourceGrantService.can_access_app(user, app):
return None, ({'error': 'Access denied'}, 403)
if app.app_type not in ['flask', 'django']:
return None, ({'error': 'Application is not a Python app'}, 400)
return app, None
# Python version management
@python_bp.route('/versions', methods=['GET'])
@jwt_required()
def get_versions():
"""Get available Python versions."""
versions = PythonService.get_python_versions()
default = PythonService.get_default_python()
return jsonify({
'versions': versions,
'default': default
}), 200
# Application creation
@python_bp.route('/apps/flask', methods=['POST'])
@jwt_required()
@admin_required
def create_flask_app():
"""Create a new Flask application."""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
required_fields = ['name', 'path']
missing = [f for f in required_fields if f not in data]
if missing:
return jsonify({'error': f'Missing required fields: {", ".join(missing)}'}), 400
python_version = data.get('python_version', '3.11')
port = data.get('port', 8000)
# Create Flask app
result = PythonService.create_flask_app(
data['path'],
data['name'],
python_version
)
if not result['success']:
return jsonify(result), 400
# Create Gunicorn service
service_result = PythonService.create_gunicorn_service(
data['name'],
data['path'],
port=port,
workers=data.get('workers', 2)
)
if not service_result['success']:
return jsonify(service_result), 400
# Create application record
current_user_id = get_jwt_identity()
app = Application(
name=data['name'],
app_type='flask',
status='stopped',
python_version=python_version,
port=port,
root_path=data['path'],
user_id=current_user_id
)
db.session.add(app)
db.session.commit()
return jsonify({
'success': True,
'app_id': app.id,
'message': 'Flask application created successfully'
}), 201
@python_bp.route('/apps/django', methods=['POST'])
@jwt_required()
@admin_required
def create_django_app():
"""Create a new Django application."""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
required_fields = ['name', 'path']
missing = [f for f in required_fields if f not in data]
if missing:
return jsonify({'error': f'Missing required fields: {", ".join(missing)}'}), 400
python_version = data.get('python_version', '3.11')
port = data.get('port', 8000)
# Create Django app
result = PythonService.create_django_app(
data['path'],
data['name'],
python_version
)
if not result['success']:
return jsonify(result), 400
# Create Gunicorn service
service_result = PythonService.create_gunicorn_service(
data['name'],
data['path'],
port=port,
workers=data.get('workers', 2)
)
if not service_result['success']:
return jsonify(service_result), 400
# Create application record
current_user_id = get_jwt_identity()
app = Application(
name=data['name'],
app_type='django',
status='stopped',
python_version=python_version,
port=port,
root_path=data['path'],
user_id=current_user_id
)
db.session.add(app)
db.session.commit()
return jsonify({
'success': True,
'app_id': app.id,
'project_name': result.get('project_name'),
'message': 'Django application created successfully'
}), 201
# Virtual environment management
@python_bp.route('/apps/<int:app_id>/venv', methods=['POST'])
@jwt_required()
@admin_required
def create_venv(app_id):
"""Create or recreate virtual environment."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
result = PythonService.create_virtualenv(app.root_path, app.python_version)
return jsonify(result), 200 if result['success'] else 400
@python_bp.route('/apps/<int:app_id>/packages', methods=['GET'])
@jwt_required()
def get_packages(app_id):
"""Get installed packages."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
packages = PythonService.get_installed_packages(app.root_path)
return jsonify({'packages': packages}), 200
@python_bp.route('/apps/<int:app_id>/packages', methods=['POST'])
@jwt_required()
@admin_required
def install_packages(app_id):
"""Install packages."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
data = request.get_json()
packages = data.get('packages') if data else None
result = PythonService.install_requirements(app.root_path, packages)
return jsonify(result), 200 if result['success'] else 400
@python_bp.route('/apps/<int:app_id>/requirements', methods=['POST'])
@jwt_required()
@admin_required
def freeze_requirements(app_id):
"""Freeze requirements to requirements.txt."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
result = PythonService.freeze_requirements(app.root_path)
return jsonify(result), 200 if result['success'] else 400
# Environment variables
@python_bp.route('/apps/<int:app_id>/env', methods=['GET'])
@jwt_required()
def get_env_vars(app_id):
"""Get environment variables."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
env_vars = PythonService.get_env_vars(app.root_path)
# Mask sensitive values
masked_vars = {}
sensitive_keys = ['SECRET', 'PASSWORD', 'KEY', 'TOKEN', 'PRIVATE']
for key, value in env_vars.items():
if any(s in key.upper() for s in sensitive_keys):
masked_vars[key] = '********'
else:
masked_vars[key] = value
return jsonify({'env_vars': masked_vars, 'count': len(env_vars)}), 200
@python_bp.route('/apps/<int:app_id>/env', methods=['PUT'])
@jwt_required()
@admin_required
def set_env_vars(app_id):
"""Set environment variables."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
data = request.get_json()
if not data or 'env_vars' not in data:
return jsonify({'error': 'env_vars is required'}), 400
result = PythonService.set_env_vars(app.root_path, data['env_vars'])
return jsonify(result), 200 if result['success'] else 400
@python_bp.route('/apps/<int:app_id>/env/<key>', methods=['DELETE'])
@jwt_required()
@admin_required
def delete_env_var(app_id, key):
"""Delete an environment variable."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
result = PythonService.delete_env_var(app.root_path, key)
return jsonify(result), 200 if result['success'] else 400
# Process control
@python_bp.route('/apps/<int:app_id>/start', methods=['POST'])
@jwt_required()
@admin_required
def start_app(app_id):
"""Start the application."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
result = PythonService.start_app(app.name)
if result['success']:
app.status = 'running'
db.session.commit()
return jsonify(result), 200 if result['success'] else 400
@python_bp.route('/apps/<int:app_id>/stop', methods=['POST'])
@jwt_required()
@admin_required
def stop_app(app_id):
"""Stop the application."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
result = PythonService.stop_app(app.name)
if result['success']:
app.status = 'stopped'
db.session.commit()
return jsonify(result), 200 if result['success'] else 400
@python_bp.route('/apps/<int:app_id>/restart', methods=['POST'])
@jwt_required()
@admin_required
def restart_app(app_id):
"""Restart the application."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
result = PythonService.restart_app(app.name)
if result['success']:
app.status = 'running'
db.session.commit()
return jsonify(result), 200 if result['success'] else 400
@python_bp.route('/apps/<int:app_id>/status', methods=['GET'])
@jwt_required()
def get_status(app_id):
"""Get application status."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
status = PythonService.get_app_status(app.name)
return jsonify(status), 200
# Gunicorn configuration
@python_bp.route('/apps/<int:app_id>/gunicorn', methods=['GET'])
@jwt_required()
def get_gunicorn_config(app_id):
"""Get Gunicorn configuration."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
config = PythonService.get_gunicorn_config(app.root_path)
return jsonify(config), 200
@python_bp.route('/apps/<int:app_id>/gunicorn', methods=['PUT'])
@jwt_required()
@admin_required
def update_gunicorn_config(app_id):
"""Update Gunicorn configuration."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
data = request.get_json()
if not data or 'content' not in data:
return jsonify({'error': 'content is required'}), 400
result = PythonService.save_gunicorn_config(app.root_path, data['content'])
return jsonify(result), 200 if result['success'] else 400
# Django-specific
@python_bp.route('/apps/<int:app_id>/migrate', methods=['POST'])
@jwt_required()
@admin_required
def run_migrations(app_id):
"""Run database migrations."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
result = PythonService.run_migrations(app.root_path, app.app_type)
return jsonify(result), 200 if result['success'] else 400
@python_bp.route('/apps/<int:app_id>/collectstatic', methods=['POST'])
@jwt_required()
@admin_required
def collect_static(app_id):
"""Collect static files (Django only)."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
if app.app_type != 'django':
return jsonify({'error': 'Only supported for Django apps'}), 400
result = PythonService.collect_static(app.root_path, app.app_type)
return jsonify(result), 200 if result['success'] else 400
# Command execution
@python_bp.route('/apps/<int:app_id>/run', methods=['POST'])
@jwt_required()
@admin_required
def run_command(app_id):
"""Run a command in the app context."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
data = request.get_json()
if not data or 'command' not in data:
return jsonify({'error': 'command is required'}), 400
# Basic command validation - block dangerous commands
command = data['command']
blocked = ['rm -rf /', 'mkfs', 'dd if=', ':(){:|:&};:']
if any(b in command for b in blocked):
return jsonify({'error': 'Command not allowed'}), 403
result = PythonService.run_command(app.root_path, command)
return jsonify(result), 200
# Delete application
@python_bp.route('/apps/<int:app_id>', methods=['DELETE'])
@jwt_required()
@admin_required
def delete_app(app_id):
"""Delete a Python application."""
current_user_id = get_jwt_identity()
app, error = get_app_or_404(app_id, current_user_id)
if error:
return jsonify(error[0]), error[1]
data = request.get_json() or {}
remove_files = data.get('remove_files', False)
result = PythonService.delete_app(app.name, app.root_path, remove_files)
if result['success']:
db.session.delete(app)
db.session.commit()
return jsonify(result), 200 if result['success'] else 400