A5: Extract all route groups into Flask blueprints (app.py -1735 lines)

Extract 9 route groups out of app.py into routes/ blueprints:
- routes/network.py  — DNS, DHCP, NTP, network info/test (10 routes)
- routes/wireguard.py — WireGuard keys, peers, config, enforcement (18 routes)
- routes/cells.py    — cell-to-cell connections (5 routes)
- routes/peers.py    — peer CRUD + IP update + _next_peer_ip helper (10 routes)
- routes/routing.py  — NAT, peer routes, firewall, iptables (17 routes)
- routes/vault.py    — certs, trust, secrets (19 routes)
- routes/containers.py — containers, images, volumes (14 routes)
- routes/services.py — service bus, logs, services status/connectivity (18 routes)
- routes/peer_dashboard.py — peer-scoped dashboard/services (2 routes)

All blueprints use lazy `from app import X` inside route bodies to preserve
test patch compatibility (patch('app.email_manager', mock) still works).

Also included in this commit:
- A1 fix: backup/restore now includes email/calendar user files
- A2 fix: apply_config sets applying=True flag via helper container
- A3 fix: add_peer rolls back firewall on DNS failure

app.py reduced: 3011 → 1294 lines. 1021 tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 06:11:21 -04:00
parent d54844cd44
commit 09138fbc18
16 changed files with 2108 additions and 2072 deletions
+195
View File
@@ -0,0 +1,195 @@
import logging
import os
from flask import Blueprint, request, jsonify, current_app
logger = logging.getLogger('picell')
bp = Blueprint('containers', __name__)
@bp.route('/api/containers', methods=['GET'])
def list_containers():
try:
from app import container_manager, is_local_request
if not is_local_request():
return jsonify({'error': 'Access denied'}), 403
return jsonify(container_manager.list_containers())
except Exception as e:
logger.error(f"Error listing containers: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/containers/<name>/start', methods=['POST'])
def start_container(name):
try:
from app import container_manager, is_local_request
if not is_local_request():
return jsonify({'error': 'Access denied'}), 403
return jsonify({'started': container_manager.start_container(name)})
except Exception as e:
logger.error(f"Error starting container {name}: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/containers/<name>/stop', methods=['POST'])
def stop_container(name):
try:
from app import container_manager, is_local_request
if not is_local_request():
return jsonify({'error': 'Access denied'}), 403
return jsonify({'stopped': container_manager.stop_container(name)})
except Exception as e:
logger.error(f"Error stopping container {name}: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/containers/<name>/restart', methods=['POST'])
def restart_container(name):
try:
from app import container_manager, is_local_request
if not is_local_request():
return jsonify({'error': 'Access denied'}), 403
return jsonify({'restarted': container_manager.restart_container(name)})
except Exception as e:
logger.error(f"Error restarting container {name}: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/containers/<name>/logs', methods=['GET'])
def get_container_logs(name):
try:
from app import container_manager, is_local_request
if not is_local_request():
return jsonify({'error': 'Access denied'}), 403
tail = request.args.get('tail', default=100, type=int)
return jsonify({'logs': container_manager.get_container_logs(name, tail=tail)})
except Exception as e:
logger.error(f"Error getting logs for container {name}: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/containers/<name>/stats', methods=['GET'])
def get_container_stats(name):
try:
from app import container_manager, is_local_request
if not is_local_request():
return jsonify({'error': 'Access denied'}), 403
return jsonify(container_manager.get_container_stats(name))
except Exception as e:
logger.error(f"Error getting stats for container {name}: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/containers', methods=['POST'])
def create_container():
try:
from app import container_manager, is_local_request
if not is_local_request():
return jsonify({'error': 'Access denied'}), 403
data = request.get_json(silent=True)
if not data or 'image' not in data:
return jsonify({'error': 'Missing image parameter'}), 400
name = data.get('name', '')
env = data.get('env', {})
secrets = data.get('secrets', [])
if secrets:
for secret_name in secrets:
secret_value = current_app.vault_manager.get_secret(secret_name)
if secret_value is not None:
env[secret_name] = secret_value
volumes = data.get('volumes', {})
if volumes:
allowed_prefixes = ('/home/roof/pic/data/', '/home/roof/pic/config/', '/tmp/')
for host_path in volumes.keys():
resolved = os.path.realpath(str(host_path))
if not any(resolved.startswith(p) for p in allowed_prefixes):
return jsonify({'error': f'Volume mount not allowed: {host_path}'}), 403
result = container_manager.create_container(
image=data['image'],
name=name,
env=env,
volumes=volumes,
command=data.get('command', ''),
ports=data.get('ports', {})
)
if 'error' in result:
return jsonify(result), 500
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/containers/<name>', methods=['DELETE'])
def remove_container(name):
try:
from app import container_manager, is_local_request
if not is_local_request():
return jsonify({'error': 'Access denied'}), 403
force = request.args.get('force', default=False, type=bool)
return jsonify({'removed': container_manager.remove_container(name, force=force)})
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/images', methods=['GET'])
def list_images():
try:
from app import container_manager, is_local_request
if not is_local_request():
return jsonify({'error': 'Access denied'}), 403
return jsonify(container_manager.list_images())
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/images/pull', methods=['POST'])
def pull_image():
try:
from app import container_manager, is_local_request
if not is_local_request():
return jsonify({'error': 'Access denied'}), 403
data = request.get_json(silent=True)
if not data or 'image' not in data:
return jsonify({'error': 'Missing image parameter'}), 400
result = container_manager.pull_image(data['image'])
if 'error' in result:
return jsonify(result), 500
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/images/<image>', methods=['DELETE'])
def remove_image(image):
try:
from app import container_manager, is_local_request
if not is_local_request():
return jsonify({'error': 'Access denied'}), 403
force = request.args.get('force', default=False, type=bool)
return jsonify({'removed': container_manager.remove_image(image, force=force)})
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/volumes', methods=['GET'])
def list_volumes():
try:
from app import container_manager, is_local_request
if not is_local_request():
return jsonify({'error': 'Access denied'}), 403
return jsonify(container_manager.list_volumes())
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/volumes', methods=['POST'])
def create_volume():
try:
from app import container_manager, is_local_request
if not is_local_request():
return jsonify({'error': 'Access denied'}), 403
data = request.get_json(silent=True)
if not data or 'name' not in data:
return jsonify({'error': 'Missing name parameter'}), 400
result = container_manager.create_volume(data['name'])
if 'error' in result:
return jsonify(result), 500
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@bp.route('/api/volumes/<name>', methods=['DELETE'])
def remove_volume(name):
try:
from app import container_manager, is_local_request
if not is_local_request():
return jsonify({'error': 'Access denied'}), 403
force = request.args.get('force', default=False, type=bool)
return jsonify({'removed': container_manager.remove_volume(name, force=force)})
except Exception as e:
return jsonify({'error': str(e)}), 500