Files
ServerKit/backend/app/sockets.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

718 lines
23 KiB
Python

from flask_socketio import SocketIO, emit, join_room, leave_room
from flask_jwt_extended import decode_token
from flask import request, current_app
import threading
import time
import queue
from app.services.system_service import SystemService
from app.services.log_service import LogService, LogStreamer
from app.services.docker_service import DockerService
socketio = SocketIO()
log_streamer = LogStreamer()
# Store active metric subscriptions
metric_subscribers = set()
metric_thread = None
metric_stop_event = threading.Event()
# Store active aggregated container-status subscriptions
container_status_subscribers = set()
container_status_thread = None
container_status_stop_event = threading.Event()
# Store active container log streams
container_log_streams = {} # sid -> {'process': Popen, 'app_id': int, 'thread': Thread, 'stop_event': Event}
# Store active pipeline subscriptions
pipeline_subscribers = {} # sid -> set of project_ids
# Authenticated identity per connected client. Populated at connect time
# (after the JWT is verified AND the user is confirmed active) and read by
# the room/subscription handlers to make authorization decisions. Without
# this, every handler only knew "the token decoded" — not who the user is or
# what role they hold — so a viewer could join a developer's live terminal
# room. Keyed by request.sid; cleaned up on disconnect. Guarded by a lock
# because async_mode='threading' runs handlers across worker threads.
connected_clients = {} # sid -> {'user_id': ..., 'role': ...}
_connected_clients_lock = threading.Lock()
# Roles permitted to drive/observe privileged server surfaces (remote
# terminals). Mirrors the REST side, where terminal create/input/kill are
# @developer_required — viewing the live PTY stream must require the same.
_PRIVILEGED_ROLES = ('admin', 'developer')
def _client_role(sid):
"""Return the authenticated role for a connected socket, or None if the
socket isn't in our authenticated set (shouldn't happen post-connect)."""
with _connected_clients_lock:
info = connected_clients.get(sid)
return info['role'] if info else None
def _client_is_privileged(sid):
"""True when the connected socket belongs to an admin/developer — the
roles allowed to attach to remote terminal streams."""
return _client_role(sid) in _PRIVILEGED_ROLES
def init_socketio(app):
"""Initialize SocketIO with the Flask app."""
socketio.init_app(
app,
cors_allowed_origins=app.config.get('CORS_ORIGINS', '*'),
async_mode='threading'
)
return socketio
@socketio.on('connect')
def handle_connect(auth):
"""Handle client connection.
Authenticates the socket and records the caller's identity + role so the
per-room handlers can authorize. A token that merely decodes is NOT enough:
we also confirm the user still exists and is active, then stash the role.
A deactivated/deleted account whose JWT hasn't expired yet is rejected here
rather than being allowed to keep streaming.
"""
# Verify JWT token from auth payload (not query string, to avoid token leakage in logs)
token = None
if auth and isinstance(auth, dict):
token = auth.get('token')
if not token:
emit('error', {'message': 'Token required'})
return False
try:
decoded = decode_token(token)
except Exception:
emit('error', {'message': 'Invalid token'})
return False
# Resolve the identity to a live, active user. decode_token only proves the
# token is well-formed and unexpired — it says nothing about whether the
# account is still allowed in.
from app.models import User
user_id = decoded.get('sub')
user = User.query.get(user_id) if user_id is not None else None
if not user or not user.is_active:
emit('error', {'message': 'Account not found or deactivated'})
return False
with _connected_clients_lock:
connected_clients[request.sid] = {'user_id': user.id, 'role': user.role}
# Join a per-user room so the Notification Bus can push in-app notifications
# to every tab/device this user has open.
join_room(f'user_{user.id}')
emit('connected', {'status': 'connected'})
@socketio.on('disconnect')
def handle_disconnect():
"""Handle client disconnection."""
sid = request.sid
# Remove from metric subscribers
if sid in metric_subscribers:
metric_subscribers.remove(sid)
# Remove from container-status subscribers
if sid in container_status_subscribers:
container_status_subscribers.remove(sid)
# Stop any log streams for this client
log_streamer.stop_stream(sid)
# Stop any container log streams for this client
stop_container_log_stream(sid)
# Remove from pipeline subscribers
pipeline_subscribers.pop(sid, None)
# Drop the authenticated-identity record for this socket.
with _connected_clients_lock:
connected_clients.pop(sid, None)
@socketio.on('subscribe_metrics')
def handle_subscribe_metrics():
"""Subscribe to real-time system metrics."""
global metric_thread, metric_stop_event
sid = request.sid
metric_subscribers.add(sid)
# Start metric broadcast thread if not running
if metric_thread is None or not metric_thread.is_alive():
metric_stop_event.clear()
metric_thread = threading.Thread(target=broadcast_metrics, daemon=True)
metric_thread.start()
emit('subscribed', {'channel': 'metrics'})
@socketio.on('unsubscribe_metrics')
def handle_unsubscribe_metrics():
"""Unsubscribe from system metrics."""
sid = request.sid
if sid in metric_subscribers:
metric_subscribers.remove(sid)
emit('unsubscribed', {'channel': 'metrics'})
def broadcast_metrics():
"""Broadcast system metrics to all subscribers."""
global metric_stop_event
while not metric_stop_event.is_set() and metric_subscribers:
try:
metrics = SystemService.get_all_metrics()
socketio.emit('metrics', metrics, room=None) # Broadcast to all
except Exception as e:
print(f"Error broadcasting metrics: {e}")
time.sleep(2) # Update every 2 seconds
# ==================== AGGREGATED CONTAINER STATUS ====================
@socketio.on('subscribe_container_status')
def handle_subscribe_container_status():
"""Subscribe to aggregated container-status change events.
Mirrors the metrics pattern: a single background thread polls the
aggregator and broadcasts ONLY the apps whose status changed since the last
tick (channel 'container_status'). Clients reconcile by app_id.
"""
global container_status_thread, container_status_stop_event
sid = request.sid
container_status_subscribers.add(sid)
if container_status_thread is None or not container_status_thread.is_alive():
container_status_stop_event.clear()
container_status_thread = threading.Thread(
target=broadcast_container_status,
args=(current_app._get_current_object(),),
daemon=True,
)
container_status_thread.start()
emit('subscribed', {'channel': 'container_status'})
@socketio.on('unsubscribe_container_status')
def handle_unsubscribe_container_status():
"""Unsubscribe from aggregated container-status events."""
sid = request.sid
if sid in container_status_subscribers:
container_status_subscribers.remove(sid)
emit('unsubscribed', {'channel': 'container_status'})
def broadcast_container_status(flask_app):
"""Broadcast changed aggregated container statuses to all subscribers.
Runs in a background thread for as long as there are subscribers. Each tick
asks the aggregator for the deltas (it keeps the last-emitted snapshot in
memory) and emits only those. Defensive: any Docker/DB error is swallowed so
the loop survives a transient outage. Needs an app context because the
aggregator touches the ORM.
"""
global container_status_stop_event
from app.services import container_status_service as css
while not container_status_stop_event.is_set() and container_status_subscribers:
try:
with flask_app.app_context():
changed = css.get_changed_app_statuses()
if changed:
socketio.emit('container_status', {
'statuses': changed,
'timestamp': time.time(),
}, room=None)
except Exception as e:
print(f"Error broadcasting container status: {e}")
time.sleep(5) # Re-evaluate every 5 seconds
@socketio.on('subscribe_terminal')
def handle_subscribe_terminal(data):
"""Join the stream room for a remote terminal session (agent PTY output).
The agent streams base64 PTY output on channel `terminal:<session_id>`;
the agent gateway rebroadcasts it as `server_stream` events into the room
`server_<server_id>_terminal:<session_id>`. This handler is what lets a
browser join that room — without it the output never reaches the UI.
Session ids are unguessable uuids minted by TerminalService for the
authenticated creator.
"""
from app.services.terminal_service import TerminalService
# Attaching to a live PTY stream exposes everything typed and printed in
# that shell (often running as root on the agent host). Creating a terminal
# is @developer_required on the REST side, so observing one must demand the
# same role — otherwise a read-only viewer could join the stream room and
# watch a privileged session.
if not _client_is_privileged(request.sid):
emit('error', {'message': 'Developer role required for terminal access'})
return
session_id = (data or {}).get('session_id')
if not session_id:
emit('error', {'message': 'session_id required'})
return
session = TerminalService.get_session(session_id)
if not session:
emit('error', {'message': 'Unknown terminal session'})
return
join_room(f"server_{session['server_id']}_terminal:{session_id}")
emit('subscribed', {'channel': f'terminal:{session_id}'})
@socketio.on('unsubscribe_terminal')
def handle_unsubscribe_terminal(data):
"""Leave a terminal session's stream room."""
from app.services.terminal_service import TerminalService
session_id = (data or {}).get('session_id')
if not session_id:
return
session = TerminalService.get_session(session_id)
if session:
leave_room(f"server_{session['server_id']}_terminal:{session_id}")
@socketio.on('subscribe_logs')
def handle_subscribe_logs(data):
"""Subscribe to real-time log streaming."""
sid = request.sid
filepath = data.get('path')
if not filepath:
emit('error', {'message': 'Log path required'})
return
# Start log stream
log_queue = log_streamer.start_stream(sid, filepath)
# Create thread to emit log updates
def emit_logs():
while True:
try:
log_data = log_queue.get(timeout=30)
if 'error' in log_data:
socketio.emit('log_error', log_data, room=sid)
break
socketio.emit('log_line', log_data, room=sid)
except:
break
thread = threading.Thread(target=emit_logs, daemon=True)
thread.start()
emit('subscribed', {'channel': 'logs', 'path': filepath})
@socketio.on('unsubscribe_logs')
def handle_unsubscribe_logs():
"""Unsubscribe from log streaming."""
sid = request.sid
log_streamer.stop_stream(sid)
emit('unsubscribed', {'channel': 'logs'})
@socketio.on('join_room')
def handle_join_room(data):
"""Join a specific room for targeted broadcasts.
This is the generic join used by job-progress and cloudflared-login
streaming (rooms shaped `server_<id>_<channel>`). It is deliberately
permissive for those — the data mirrors what any authenticated user can
already pull over REST — but it must NOT become a side door into the
privileged terminal stream rooms (`server_<id>_terminal:<session>`), which
`subscribe_terminal` gates by role. Enforce that gate here too so the
generic primitive can't be used to bypass it.
"""
room = data.get('room')
if not room or not isinstance(room, str):
emit('error', {'message': 'room required'})
return
if '_terminal:' in room and not _client_is_privileged(request.sid):
emit('error', {'message': 'Developer role required for terminal access'})
return
join_room(room)
emit('joined', {'room': room})
@socketio.on('leave_room')
def handle_leave_room(data):
"""Leave a specific room."""
room = data.get('room')
if room:
leave_room(room)
emit('left', {'room': room})
# ==================== DEPLOY CONSOLE STREAMING ====================
#
# Live push for the Deploy Console (plan 51). Room is per DeploymentJob
# (`deploy_{job_id}`); the RunLogStream seam batches writes and emits one
# `deploy_log` (a batch of persisted lines) per flush plus `deploy_status`
# (the job summary dict) on step/terminal transitions. This is an ACCELERATOR
# on top of the `GET /deployment-jobs/<id>/logs?after_id=` polling endpoint —
# the console stays 100% functional with sockets disabled (D2).
@socketio.on('subscribe_deploy')
def handle_subscribe_deploy(data):
"""Subscribe to live Deploy Console updates for a deployment job.
Auth mirrors the read API (D3/§8): any authenticated user may watch — job
ids are unguessable UUIDs and the REST read endpoint exposes them the same
way. The connect handler already verified the JWT and recorded identity in
`connected_clients`; a socket missing from that set is not authenticated.
"""
sid = request.sid
if sid not in connected_clients:
emit('error', {'message': 'Authentication required'})
return
job_id = (data or {}).get('job_id')
if not job_id:
emit('error', {'message': 'job_id required'})
return
join_room(f'deploy_{job_id}')
emit('subscribed', {'channel': 'deploy', 'job_id': job_id})
@socketio.on('unsubscribe_deploy')
def handle_unsubscribe_deploy(data=None):
"""Leave a deployment job's live room."""
job_id = (data or {}).get('job_id')
if job_id:
leave_room(f'deploy_{job_id}')
emit('unsubscribed', {'channel': 'deploy', 'job_id': job_id})
def emit_deploy_log(job_id: str, lines: list):
"""Emit a batch of persisted deploy log lines to a job's room.
Called from RunLogStream on each flush. `lines` is a list of
{id, step_index, level, message, ts} dicts (already persisted, carrying
real DB ids so the client can dedupe + after_id re-sync).
"""
socketio.emit('deploy_log', {
'job_id': job_id,
'lines': lines,
}, room=f'deploy_{job_id}')
def emit_deploy_status(job_id: str, status: dict):
"""Emit a deployment job status summary (same shape as
GET /deployment-jobs/<id>, without logs) to the job's room."""
socketio.emit('deploy_status', {
'job_id': job_id,
'status': status,
}, room=f'deploy_{job_id}')
# ==================== CONTAINER LOG STREAMING ====================
@socketio.on('subscribe_container_logs')
def handle_subscribe_container_logs(data):
"""Subscribe to real-time container log streaming.
data: {
'app_id': int,
'tail': int (optional, default 100),
'since': str (optional),
'service': str (optional, for compose apps)
}
Emits:
- 'subscribed': Confirmation with app_id and container info
- 'container_log': Log lines as they arrive
- 'container_log_error': If streaming fails
- 'container_log_ended': When stream ends (container stopped)
"""
from app.models import Application, User
from app import db
sid = request.sid
app_id = data.get('app_id')
tail = data.get('tail', 100)
since = data.get('since')
service = data.get('service')
if not app_id:
emit('error', {'message': 'app_id required'})
return
# Stop any existing stream for this client
stop_container_log_stream(sid)
# Get app and verify access
try:
app = Application.query_active().filter_by(id=app_id).first()
if not app:
emit('container_log_error', {'message': 'Application not found', 'app_id': app_id})
return
except Exception as e:
emit('container_log_error', {'message': f'Database error: {str(e)}', 'app_id': app_id})
return
# Get container ID
all_containers = DockerService.get_all_app_containers(app)
container_id = None
container_name = None
if service:
for c in all_containers:
if c.get('service') == service or c.get('name') == service:
container_id = c.get('id') or c.get('name')
container_name = c.get('name')
break
else:
container_id = DockerService.get_app_container_id(app)
if all_containers:
container_name = all_containers[0].get('name')
if not container_id:
emit('container_log_error', {
'message': 'No container found for this application',
'app_id': app_id,
'hint': 'The application may not have been started yet'
})
return
# Check container state
container_state = DockerService.get_container_state(container_id)
if not container_state:
emit('container_log_error', {
'message': 'Container not found or no longer exists',
'app_id': app_id
})
return
# Join room for this app's logs
join_room(f'logs_{app_id}')
# Start streaming process
process = DockerService.stream_container_logs(
container_id,
tail=tail,
since=since,
timestamps=True
)
if not process:
emit('container_log_error', {
'message': 'Failed to start log stream',
'app_id': app_id
})
return
# Create stop event for this stream
stop_event = threading.Event()
# Create thread to read and emit logs
def stream_logs():
try:
while not stop_event.is_set():
line = process.stdout.readline()
if not line:
# Process ended (container stopped or exited)
if not stop_event.is_set():
socketio.emit('container_log_ended', {
'app_id': app_id,
'message': 'Container log stream ended'
}, room=f'logs_{app_id}')
break
# Parse the log line
parsed = DockerService.parse_log_line(line.rstrip('\n'))
socketio.emit('container_log', {
'app_id': app_id,
'line': line.rstrip('\n'),
'parsed': parsed,
'timestamp': time.time()
}, room=f'logs_{app_id}')
except Exception as e:
if not stop_event.is_set():
socketio.emit('container_log_error', {
'app_id': app_id,
'message': f'Stream error: {str(e)}'
}, room=f'logs_{app_id}')
finally:
# Clean up
try:
process.terminate()
process.wait(timeout=2)
except:
try:
process.kill()
except:
pass
thread = threading.Thread(target=stream_logs, daemon=True)
thread.start()
# Store stream info for cleanup
container_log_streams[sid] = {
'process': process,
'app_id': app_id,
'thread': thread,
'stop_event': stop_event,
'container_id': container_id
}
emit('subscribed', {
'channel': 'container_logs',
'app_id': app_id,
'container_id': container_id,
'container_name': container_name,
'container_state': container_state,
'containers': all_containers
})
@socketio.on('unsubscribe_container_logs')
def handle_unsubscribe_container_logs():
"""Unsubscribe from container log streaming."""
sid = request.sid
stream_info = container_log_streams.get(sid)
if stream_info:
app_id = stream_info.get('app_id')
leave_room(f'logs_{app_id}')
stop_container_log_stream(sid)
emit('unsubscribed', {'channel': 'container_logs'})
def stop_container_log_stream(sid: str):
"""Stop a container log stream for a specific session.
Args:
sid: Socket session ID
"""
stream_info = container_log_streams.pop(sid, None)
if stream_info:
# Signal thread to stop
stop_event = stream_info.get('stop_event')
if stop_event:
stop_event.set()
# Terminate the process
process = stream_info.get('process')
if process:
try:
process.terminate()
process.wait(timeout=2)
except:
try:
process.kill()
except:
pass
def emit_container_log(app_id: int, line: str, level: str = 'info'):
"""Emit a container log line to all subscribers.
This function can be called externally to inject log messages.
"""
socketio.emit('container_log', {
'app_id': app_id,
'line': line,
'parsed': {
'timestamp': None,
'message': line,
'level': level
},
'timestamp': time.time()
}, room=f'logs_{app_id}')
# ==================== PIPELINE EVENT STREAMING ====================
@socketio.on('subscribe_pipeline')
def handle_subscribe_pipeline(data):
"""Subscribe to real-time pipeline events for a WordPress project.
data: {
'project_id': int (production site ID)
}
Emits 'pipeline_event' with:
- project_id: int
- event: string (e.g. 'promotion_started', 'sync_completed')
- data: dict with event-specific details
- timestamp: float
"""
sid = request.sid
project_id = data.get('project_id')
if not project_id:
emit('error', {'message': 'project_id required'})
return
room = f'pipeline_{project_id}'
join_room(room)
if sid not in pipeline_subscribers:
pipeline_subscribers[sid] = set()
pipeline_subscribers[sid].add(project_id)
emit('subscribed', {'channel': 'pipeline', 'project_id': project_id})
@socketio.on('unsubscribe_pipeline')
def handle_unsubscribe_pipeline(data):
"""Unsubscribe from pipeline events for a project."""
sid = request.sid
project_id = data.get('project_id')
if project_id:
leave_room(f'pipeline_{project_id}')
if sid in pipeline_subscribers:
pipeline_subscribers[sid].discard(project_id)
if not pipeline_subscribers[sid]:
del pipeline_subscribers[sid]
emit('unsubscribed', {'channel': 'pipeline', 'project_id': project_id})
def emit_pipeline_event(project_id: int, event: str, data: dict = None):
"""Emit a pipeline event to all subscribers of a project.
Called from EnvironmentPipelineService or API endpoints to notify
the frontend about long-running operations (promote, sync, create).
Args:
project_id: Production site ID
event: Event type (e.g. 'promotion_started', 'sync_completed',
'environment_created', 'environment_deleted')
data: Event-specific payload
"""
socketio.emit('pipeline_event', {
'project_id': project_id,
'event': event,
'data': data or {},
'timestamp': time.time()
}, room=f'pipeline_{project_id}')