Files
ServerKit/backend/app/services/container_sleep_service.py
Juan Denis 97a96d963b Give common API errors stable codes and service wording
Toasts showed backend prose verbatim, with no code to translate
against and "application" where the UI says "service". Not-found,
missing-field, permission, conflict and agent-offline errors now come
from factories in app/exceptions.py that pair one fixed message with
a dotted code (not_found.<resource>, validation.required,
permission.denied, conflict.exists, agent.offline), so the client can
translate them.

The shared app-loading helpers and the apps, domains, servers,
databases and backups routes raise those errors instead of hand
shaping bodies, which drops the hand-shaped body count from 1132 to
850. Five routes picked an HTTP status by comparing error text; they
now key off a result code or a sentinel so rewording can't change a
status. Five broad excepts that echoed str(exc) now log the crash and
return the request id instead.
2026-10-04 18:59:25 -04:00

127 lines
5.5 KiB
Python

"""Container auto-sleep: stop idle apps to free resources and wake them on
demand. Idle is measured from `last_activity_at` (bumped on wake and via
record_activity); wiring real traffic into record_activity — e.g. from the
nginx access log — and request-triggered wake-on-demand are follow-ups."""
import logging
from datetime import datetime, timedelta
from app import db
from app.models import Application
from app.models.container_sleep_policy import ContainerSleepPolicy
from app.services.docker_service import DockerService
logger = logging.getLogger(__name__)
class ContainerSleepService:
@staticmethod
def get_or_create_policy(application_id):
policy = ContainerSleepPolicy.query.filter_by(application_id=application_id).first()
if not policy:
policy = ContainerSleepPolicy(application_id=application_id)
db.session.add(policy)
db.session.commit()
return policy
@classmethod
def set_policy(cls, application_id, enabled=None, idle_timeout_minutes=None):
policy = cls.get_or_create_policy(application_id)
if enabled is not None:
policy.enabled = bool(enabled)
if idle_timeout_minutes is not None:
policy.idle_timeout_minutes = max(1, int(idle_timeout_minutes))
# (Re)configuring the policy resets the idle clock.
policy.last_activity_at = datetime.utcnow()
db.session.commit()
return policy
@staticmethod
def record_activity(application_id):
policy = ContainerSleepPolicy.query.filter_by(application_id=application_id).first()
if policy:
policy.last_activity_at = datetime.utcnow()
db.session.commit()
return policy
@classmethod
def _stop(cls, app):
if app.server_id:
return {'success': False, 'error': 'Sleep is not yet available for services on remote servers'}
from app.services.slot_deploy_service import SlotDeployService
if SlotDeployService.is_compose_slot_app(app):
return SlotDeployService.live_action(app, 'stop')
if app.app_type == 'docker' and app.root_path and app.compose_file:
return DockerService.compose_down(app.root_path, compose_file=app.compose_file)
if app.container_id:
return DockerService.stop_container(app.container_id)
return {'success': False, 'error': 'No container or compose project to stop'}
@classmethod
def _start(cls, app):
if app.server_id:
return {'success': False, 'error': 'Wake is not yet available for services on remote servers'}
from app.services.slot_deploy_service import SlotDeployService
if SlotDeployService.is_compose_slot_app(app):
return SlotDeployService.live_action(app, 'start')
if app.app_type == 'docker' and app.root_path and app.compose_file:
return DockerService.compose_up(app.root_path, detach=True, compose_file=app.compose_file)
if app.container_id:
return DockerService.start_container(app.container_id)
return {'success': False, 'error': 'No container or compose project to start'}
@classmethod
def sleep_app(cls, application_id):
# query_active: this stops containers — never touch a tombstoned app.
app = Application.query_active().filter_by(id=application_id).first()
if not app:
return {'success': False, 'error': 'Service not found'}
result = cls._stop(app)
if not result.get('success'):
return result
policy = cls.get_or_create_policy(application_id)
policy.asleep = True
app.status = 'stopped'
db.session.commit()
logger.info('Container auto-sleep: %s asleep', app.name)
return {'success': True, 'policy': policy.to_dict()}
@classmethod
def wake_app(cls, application_id):
# query_active: waking a deleted app would restart the very containers
# the delete tore down.
app = Application.query_active().filter_by(id=application_id).first()
if not app:
return {'success': False, 'error': 'Service not found'}
result = cls._start(app)
if not result.get('success'):
return result
policy = cls.get_or_create_policy(application_id)
policy.asleep = False
policy.last_activity_at = datetime.utcnow()
app.status = 'running'
db.session.commit()
logger.info('Container auto-sleep: %s awake', app.name)
return {'success': True, 'policy': policy.to_dict()}
@classmethod
def sweep_idle(cls):
"""Sleep every enabled, awake app whose recorded activity is older than
its idle timeout. Meant to run periodically (cron/scheduler)."""
now = datetime.utcnow()
slept = []
policies = ContainerSleepPolicy.query.filter_by(enabled=True, asleep=False).all()
# Policies outlive a soft-deleted app; without this the scheduler keeps
# running compose_down against a deleted app forever.
dead = Application.deleted_ids()
for policy in policies:
if policy.application_id in dead:
continue
if not policy.last_activity_at:
continue # no activity baseline yet — never sleep blind
if now - policy.last_activity_at < timedelta(minutes=policy.idle_timeout_minutes):
continue
if cls.sleep_app(policy.application_id).get('success'):
slept.append(policy.application_id)
return {'success': True, 'slept': slept}