Files
ServerKit/backend/app/api/apps.py
T
Juan Denis 99a19c2fb7 Read 40 more route bodies through json_object()
The first pass left routes that hand the body straight to a service
(create_zone(data), MonitorService.create(data), ...). Those services
read it as a dict, so a body of null, [] or a bare string still
crashed them into a 500. Agent long-poll, external webhook receivers
and the routes that already type-check their body are left alone.
2026-10-10 21:48:18 -04:00

2399 lines
90 KiB
Python

# Bucket: PER-APP. Per-app routes gate on the shared app-access
# seam via the file-local _can_access_app (reads) / _can_edit_app (writes)
# helpers, or the hand-rolled owner-or-admin idiom; host/system ops stay admin.
import os
import json
import re
from datetime import datetime
from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import jwt_required, get_jwt_identity
from sqlalchemy.orm import joinedload
from app import db
from app.api._query import apply_query, QueryParseError
from app.models import Application, User
from app.utils.slug import app_name_clash_error, slugify
from app.services.build_service import BuildService
from app.services.docker_service import DockerService
from app.services.git_service import GitService
from app.services.repository_manifest_service import RepositoryManifestService
from app.services.source_connection_service import SourceConnectionService
from app.services.remote_docker_service import RemoteDockerService
from app.services.container_registry_service import ContainerRegistryService
from app.services.image_update_service import ImageUpdateService
from app.services import container_status_service, application_lifecycle_service, repository_application_service
from app.services.application_lifecycle_service import (
_compose_target, _local_compose_file, _agent_result_failed,
_agent_result_error, _assert_managed_app_path,
)
from app.services.container_sleep_service import ContainerSleepService
from app.services.log_service import LogService
from app.services.process_service import ProcessService
from app.services.backup_policy_service import BackupPolicyService, BackupPolicyError
from app.services.upload_service import (
ensure_app_dirs,
validate_zip,
extract_version,
preserve_existing_env,
switch_current_version,
backup_current,
save_upload_zip,
detect_app_type,
get_app_storage_dir,
list_versions,
get_current_version,
)
from app import paths
from app.middleware.rbac import get_current_user, require_admin_user
from app.error_reporting import unexpected_response
from app.exceptions import (
PermissionDeniedError, ValidationError, field_required, not_found, permission_denied,
)
from app.api._access import require_app
from app.api._request import json_object
apps_bp = Blueprint('apps', __name__)
def _remove_data_flag(app):
"""Should ``DELETE /apps/<id>`` take the data volumes down with the app?
Explicit wins: ``?remove_data=`` (alias ``?volumes=``) is honored as given.
With neither present, database engines default to PRESERVING their volumes
-- the data is the whole point of the engine and its loss is the only
irreversible part of an uninstall -- while every other app keeps the
historic "remove volumes too" behavior.
"""
raw = request.args.get('remove_data')
if raw is None:
raw = request.args.get('volumes')
if raw is not None:
return str(raw).strip().lower() in ('1', 'true', 'yes', 'on')
try:
from app.services.database_engine_service import is_engine_app
return not is_engine_app(app)
except Exception:
return True
def _sync_manual_app_status(app):
"""Update app.status from the real runtime for manual/local apps."""
if app.source != 'manual':
return
if app.managed_by == 'systemd' and app.systemd_unit:
status = ProcessService.get_systemd_unit_status(app.systemd_unit)
actual = 'running' if status.get('active') else 'stopped'
elif app.managed_by == 'docker_compose' and app.root_path:
status = ProcessService.get_compose_project_status(app.root_path, compose_file=app.compose_file)
actual = status.get('status', 'stopped')
else:
return
if actual in ('running', 'stopped') and app.status != actual:
app.status = actual
db.session.commit()
def _service_slug(value):
return slugify(value)
def _derive_repo_app_type(detection):
if detection.get('has_dockerfile') or detection.get('has_docker_compose'):
return 'docker'
framework = detection.get('framework')
language = detection.get('language')
if framework == 'django':
return 'django'
if framework in ['flask', 'fastapi'] or language == 'python':
return 'flask'
if framework == 'laravel' or language == 'php':
return 'php'
return 'static'
def _safe_repo_url(repo_url):
return re.sub(r'^(https?://)[^@]+@', r'\1', repo_url or '')
def _attach_deploy_config(payload, deploy_configs=None):
deploy_configs = deploy_configs or GitService.get_config().get('apps', {})
deploy_config = deploy_configs.get(str(payload.get('id')))
payload['last_deploy_at'] = payload.get('last_deployed_at')
payload['deploy_configured'] = deploy_config is not None
if deploy_config:
payload['deploy_repo_url'] = _safe_repo_url(deploy_config.get('repo_url'))
payload['deploy_branch'] = deploy_config.get('branch')
payload['auto_deploy'] = deploy_config.get('auto_deploy', False)
payload['last_deploy_at'] = deploy_config.get('last_deploy')
return payload
# ==================== ENVIRONMENT LINKING ====================
@apps_bp.route('/<int:app_id>/link', methods=['POST'])
@jwt_required()
def link_apps(app_id):
"""Link two apps as prod/dev pair sharing database resources."""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_edit_app(user, app):
raise permission_denied()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
target_app_id = data.get('target_app_id')
as_environment = data.get('as_environment', 'development')
if not target_app_id:
raise field_required('target_app_id')
valid_environments = ['production', 'development', 'staging']
if as_environment not in valid_environments:
return jsonify({'error': f'Invalid environment. Must be one of: {", ".join(valid_environments)}'}), 400
target_app = Application.query_active().filter_by(id=target_app_id).first()
if not target_app:
return jsonify({'error': 'Target service not found'}), 404
if not _can_edit_app(user, target_app):
return jsonify({'error': "You don't have access to the target service"}), 403
if app.app_type != target_app.app_type:
return jsonify({'error': 'Only services of the same type can be linked'}), 400
if app_id == target_app_id:
return jsonify({'error': "A service can't be linked to itself"}), 400
# Set environment types based on as_environment
if as_environment == 'development':
app.environment_type = 'development'
target_app.environment_type = 'production'
elif as_environment == 'production':
app.environment_type = 'production'
target_app.environment_type = 'development'
else: # staging
app.environment_type = 'staging'
target_app.environment_type = 'production'
# Link bidirectionally
app.linked_app_id = target_app_id
target_app.linked_app_id = app_id
# Store shared config
from datetime import datetime
shared_config = {
'linked_at': datetime.now().isoformat(),
'link_type': 'environment_pair'
}
# Propagate DB credentials for Docker/WordPress apps
propagation_result = None
if app.app_type == 'docker' and data.get('propagate_credentials', True):
from app.services.template_service import TemplateService
# Determine which app is the source (prod) and which is target (dev)
if as_environment == 'development':
# Current app is dev, target is prod - propagate from target to current
propagation_result = TemplateService.propagate_db_credentials(
target_app_id, app_id, data.get('table_prefix')
)
else:
# Current app is prod, target is dev - propagate from current to target
propagation_result = TemplateService.propagate_db_credentials(
app_id, target_app_id, data.get('table_prefix')
)
if propagation_result and propagation_result.get('success'):
shared_config['db_credentials_propagated'] = True
shared_config['shared_db'] = propagation_result.get('shared_config', {})
app.shared_config = json.dumps(shared_config)
target_app.shared_config = json.dumps(shared_config)
db.session.commit()
response = {
'message': 'Services linked',
'app': app.to_dict(include_linked=True),
'target_app': target_app.to_dict(include_linked=True)
}
if propagation_result:
response['credential_propagation'] = propagation_result
return jsonify(response), 200
@apps_bp.route('/<int:app_id>/linked', methods=['GET'])
@jwt_required()
def get_linked_apps(app_id):
"""Get apps linked to this app."""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_access_app(user, app):
raise permission_denied()
linked_apps = []
# Get directly linked app
if app.linked_app:
linked_apps.append({
'id': app.linked_app.id,
'name': app.linked_app.name,
'app_type': app.linked_app.app_type,
'environment_type': app.linked_app.environment_type,
'status': app.linked_app.status,
'port': app.linked_app.port
})
# Get apps that link to this one
for linked_from in app.linked_from:
if linked_from.id != app.linked_app_id: # Avoid duplicates
linked_apps.append({
'id': linked_from.id,
'name': linked_from.name,
'app_type': linked_from.app_type,
'environment_type': linked_from.environment_type,
'status': linked_from.status,
'port': linked_from.port
})
return jsonify({
'app_id': app_id,
'environment_type': app.environment_type,
'linked_apps': linked_apps,
'shared_config': json.loads(app.shared_config) if app.shared_config else None
}), 200
@apps_bp.route('/<int:app_id>/link', methods=['DELETE'])
@jwt_required()
def unlink_apps(app_id):
"""Unlink apps and reset environment types."""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_edit_app(user, app):
raise permission_denied()
if not app.linked_app_id:
return jsonify({'error': 'This service is not linked to another service'}), 400
# Deliberately NOT query_active: this is the reciprocal half of a link the
# caller is severing, not "an app to operate on". If the partner is already
# in the recycle bin its back-pointer still has to be cleared, or it would
# restore holding a link to an app that no longer links back.
target_app = Application.query.get(app.linked_app_id)
# Reset both apps
app.linked_app_id = None
app.environment_type = 'standalone'
app.shared_config = None
if target_app:
target_app.linked_app_id = None
target_app.environment_type = 'standalone'
target_app.shared_config = None
db.session.commit()
return jsonify({
'message': 'Services unlinked',
'app': app.to_dict()
}), 200
@apps_bp.route('/<int:app_id>/environment', methods=['PUT'])
@jwt_required()
def update_environment(app_id):
"""Update environment type for an app."""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_edit_app(user, app):
raise permission_denied()
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
environment_type = data.get('environment_type')
valid_types = ['production', 'development', 'staging', 'standalone']
if environment_type not in valid_types:
return jsonify({'error': f'Invalid environment_type. Must be one of: {", ".join(valid_types)}'}), 400
app.environment_type = environment_type
db.session.commit()
return jsonify({
'message': 'Environment type updated',
'app': app.to_dict()
}), 200
# ==================== APP CRUD ====================
@apps_bp.route('', methods=['GET'])
@jwt_required()
def get_apps():
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
# Optional filter by environment type
environment_filter = request.args.get('environment')
include_linked = request.args.get('include_linked', 'false').lower() == 'true'
# Workspace-aware scoping (#33). With no workspace context this is exactly the
# prior behavior (admin -> all, else -> own); with ?workspace_id / X-Workspace-Id
# it filters to that workspace (membership-checked).
from app.services.workspace_service import WorkspaceService
ws_id = WorkspaceService.resolve_workspace_id(
user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id'))
# query_active() is the base the whole chain narrows: scope_query and
# apply_query only ever add filters, so scoping here keeps deleted apps out
# of the list (and out of $filter/$top/total) without touching either.
query = WorkspaceService.scope_query(Application.query_active(), Application, user,
workspace_id=ws_id, owner_attr='user_id',
grant_resource_type='application')
if environment_filter:
query = query.filter_by(environment_type=environment_filter)
# $select / $filter / $orderby / $skip / $top. All optional: with none of
# them present this is a no-op and the response is byte-identical to before.
try:
narrowed = apply_query(query, Application, request,
select_extra=Application.DERIVED_FIELDS)
except QueryParseError as exc:
return jsonify({'error': str(exc)}), 400
query = narrowed.query
fields = narrowed.fields
# Eager-load the scalar relationships to_dict resolves names through, but
# ONLY when this request actually asked for those names — a joinedload for a
# field nobody selected is a wider row for nothing. `image_scans` and
# `image_update_checks` are deliberately absent: they are lazy='dynamic',
# so the attribute IS a Query and no loader option can hoist it. Declining
# them via $select is the only way to avoid their per-row cost.
eager = []
if fields is None or 'server_name' in fields:
eager.append(joinedload(Application.server))
if fields is None or 'project_name' in fields:
eager.append(joinedload(Application.project))
if fields is None or 'environment_name' in fields:
eager.append(joinedload(Application.environment))
if include_linked and (fields is None or 'linked_app' in fields):
eager.append(joinedload(Application.linked_app))
if eager:
query = query.options(*eager)
apps = query.all()
deploy_configs = GitService.get_config().get('apps', {})
payload = {
'apps': [
_attach_deploy_config(
app.to_dict(include_linked=include_linked, fields=fields), deploy_configs)
for app in apps
]
}
# Paging metadata only appears once a caller pages, so the existing
# response shape is untouched for everyone who does not.
if narrowed.total is not None:
payload['meta'] = narrowed.as_meta()
return jsonify(payload), 200
@apps_bp.route('/<int:app_id>/workspace', methods=['PUT'])
@jwt_required()
def set_app_workspace(app_id):
"""Reassign an application to a workspace (#33). Owner-or-admin on the app;
the target must be a workspace the caller can access (member or admin). A
null/'default' target moves it back to the default workspace."""
from app.models.workspace import Workspace
from app.services.workspace_service import WorkspaceService
user = get_current_user()
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
# Use user.id (int) for comparisons — get_jwt_identity() is the stringified token id.
if not user.is_admin and app.user_id != user.id:
raise permission_denied()
target = json_object().get('workspace_id')
if target in (None, '', 'default'):
ws_id = WorkspaceService.ensure_default_workspace().id
else:
ws = Workspace.query.get(target)
if not ws:
return jsonify({'error': 'Workspace not found'}), 404
if not user.is_admin and WorkspaceService.get_user_role(ws.id, user.id) is None:
return jsonify({'error': 'Not a member of the target workspace'}), 403
# Role reconciliation (#33): a 'viewer' member can't move resources into it.
if not WorkspaceService.can_write_in_workspace(user, ws.id):
return jsonify({'error': 'You have read-only access to the target workspace'}), 403
ws_id = ws.id
app.workspace_id = ws_id
db.session.commit()
return jsonify({'message': 'Workspace updated', 'app': app.to_dict()}), 200
@apps_bp.route('/move-to-project', methods=['POST'])
@jwt_required()
def move_apps_to_project():
"""Bulk-assign apps to a project/environment (or unassign them).
Body: ``{app_ids: [...], project_id, environment_id}``.
Mirrors the per-create ``_resolve_project_env`` validation, but PER APP so a
mixed-workspace selection is handled safely:
- The project must belong to the app's own workspace.
- The environment must belong to that project.
Invalid pairings are silently dropped to None (the app is unassigned rather
than mis-assigned) — never an error. ``project_id: null`` unassigns. Only
apps the caller can edit are touched; the rest are skipped. Returns the
updated rows.
"""
from app.models.project import Project
from app.models.environment import Environment
user = get_current_user()
data = json_object()
raw_ids = data.get('app_ids')
if not isinstance(raw_ids, list) or not raw_ids:
return jsonify({'error': 'app_ids must be a non-empty list'}), 400
def _as_int(v):
if v in (None, '', 'null'):
return None
try:
return int(v)
except (TypeError, ValueError):
return None
app_ids = [i for i in (_as_int(x) for x in raw_ids) if i is not None]
req_project_id = _as_int(data.get('project_id'))
req_environment_id = _as_int(data.get('environment_id'))
updated = []
skipped = []
for app_id in app_ids:
app = Application.query_active().filter_by(id=app_id).first()
if not app:
skipped.append(app_id)
continue
if not _can_edit_app(user, app):
skipped.append(app_id)
continue
if req_project_id is None:
# Explicit unassign.
app.project_id = None
app.environment_id = None
updated.append(app)
continue
# Validate the project belongs to THIS app's workspace.
project = Project.query.get(req_project_id)
if project is None or project.workspace_id != app.workspace_id:
# Invalid for this app → drop the assignment (unassign), never error.
app.project_id = None
app.environment_id = None
updated.append(app)
continue
# Validate the environment belongs to the resolved project.
environment_id = req_environment_id
if environment_id is not None:
env = Environment.query.get(environment_id)
if env is None or env.project_id != project.id:
environment_id = None
app.project_id = project.id
app.environment_id = environment_id
updated.append(app)
db.session.commit()
return jsonify({
'message': f'Updated {len(updated)} service(s)',
'apps': [a.to_dict() for a in updated],
'skipped': skipped,
}), 200
def _resolve_project_env(data, workspace_id):
"""Validate optional project_id/environment_id from a create payload against
the app's workspace. Returns (project_id, environment_id) with invalid values
silently ignored (set to None), so a bad/foreign id can never break a create.
- project must belong to `workspace_id`.
- environment must belong to that project; if an environment is supplied
without a project, its parent project is used (still workspace-checked).
"""
from app.models.project import Project
from app.models.environment import Environment
def _as_int(v):
if v in (None, '', 'null'):
return None
try:
return int(v)
except (TypeError, ValueError):
return None
project_id = _as_int(data.get('project_id'))
environment_id = _as_int(data.get('environment_id'))
if project_id is None and environment_id is None:
return None, None
env = None
if environment_id is not None:
env = Environment.query.get(environment_id)
if env is None:
environment_id = None
elif project_id is None:
project_id = env.project_id
project = Project.query.get(project_id) if project_id is not None else None
if project is None or project.workspace_id != workspace_id:
# No valid project in this workspace -> drop both.
return None, None
# Environment must belong to the resolved project.
if env is not None and env.project_id != project.id:
environment_id = None
return project.id, environment_id
def _resolve_ingress_plane(data, app_type, managed_by=None):
"""Resolve the ingress plane ('nginx' | 'proxy_stack') for a new app.
Defaults to host Nginx. A requested 'proxy_stack' is only honored for
container-based services; for every other type it falls back to nginx, so
the boundary between the two reverse proxies stays explicit and a PHP/
WordPress/static/Python app can never be tagged for a Dockerized proxy.
"""
from app.utils.ingress import normalize_ingress_plane
return normalize_ingress_plane(data.get('ingress_plane'), app_type, managed_by)
def _can_access_app(user, app):
"""Read access (#33 ACL) — delegates to the shared seam."""
from app.services.resource_grant_service import ResourceGrantService
return ResourceGrantService.can_access_app(user, app)
def _can_edit_app(user, app):
"""Write/operate access (#33 ACL) — delegates to the shared seam."""
from app.services.resource_grant_service import ResourceGrantService
return ResourceGrantService.can_edit_app(user, app)
@apps_bp.route('/<int:app_id>', methods=['GET'])
@jwt_required()
def get_app(app_id):
user = get_current_user()
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_access_app(user, app):
raise permission_denied()
# Single app requests include linked app info by default
return jsonify({'app': _attach_deploy_config(app.to_dict(include_linked=True))}), 200
@apps_bp.route('/<int:app_id>/compose-services', methods=['GET'])
@jwt_required()
def get_compose_services(app_id):
"""List the compose service names for an app.
Lets the env-var editor offer a per-service targeting choice. Empty for
non-compose apps or when the base compose can't be read.
"""
user = get_current_user()
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_access_app(user, app):
raise permission_denied()
from app.services.compose_env_service import ComposeEnvService
return jsonify({'services': ComposeEnvService.list_services(app)}), 200
# ---- Per-resource access grants (#33 per-site ACL): share an app with a user ----
@apps_bp.route('/<int:app_id>/grants', methods=['GET'])
@jwt_required()
def list_app_grants(app_id):
"""List who has been granted access to this app (owner-or-admin)."""
from app.services.resource_grant_service import ResourceGrantService
user = get_current_user()
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not user.is_admin and app.user_id != user.id:
raise permission_denied()
grants = ResourceGrantService.list_for_resource('application', app.id)
return jsonify({'grants': [g.to_dict() for g in grants]}), 200
@apps_bp.route('/<int:app_id>/grants', methods=['POST'])
@jwt_required()
def grant_app_access(app_id):
"""Grant a user access to this app (owner-or-admin). Body: {user_id, role?}."""
from app.services.resource_grant_service import ResourceGrantService
user = get_current_user()
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not user.is_admin and app.user_id != user.id:
raise permission_denied()
data = json_object()
grantee_id = data.get('user_id')
if not grantee_id:
raise field_required('user_id')
grantee = User.query.get(grantee_id)
if not grantee:
raise not_found('user')
if grantee.id == app.user_id:
return jsonify({'error': 'The owner already has access'}), 400
role = data.get('role') or 'editor'
if role not in ('viewer', 'editor'):
return jsonify({'error': "role must be 'viewer' or 'editor'"}), 400
grant = ResourceGrantService.grant(user_id=grantee.id, resource_type='application',
resource_id=app.id, granted_by=user.id, role=role)
return jsonify({'grant': grant.to_dict()}), 201
@apps_bp.route('/<int:app_id>/grants/<int:grant_id>', methods=['DELETE'])
@jwt_required()
def revoke_app_access(app_id, grant_id):
"""Revoke a grant on this app (owner-or-admin)."""
from app.services.resource_grant_service import ResourceGrantService
user = get_current_user()
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not user.is_admin and app.user_id != user.id:
raise permission_denied()
ok = ResourceGrantService.revoke(grant_id, resource_type='application', resource_id=app.id)
return jsonify({'success': ok}), (200 if ok else 404)
@apps_bp.route('/from-repository', methods=['POST'])
@jwt_required()
def create_app_from_repository():
"""Create a new application by cloning a Git repository."""
user = require_admin_user()
data = json_object()
name = _service_slug(data.get('name'))
repo_url = (data.get('repo_url') or '').strip()
source_connection_id = data.get('source_connection_id')
repository_full_name = (data.get('repository_full_name') or '').strip()
branch = (data.get('branch') or 'main').strip()
app_type = (data.get('app_type') or 'auto').strip().lower()
build_method = (data.get('build_method') or 'auto').strip().lower()
auto_deploy = bool(data.get('auto_deploy', True))
port = data.get('port')
dockerfile_path = (data.get('dockerfile_path') or '').strip() or None
custom_build_cmd = (data.get('custom_build_cmd') or '').strip() or None
custom_start_cmd = (data.get('custom_start_cmd') or '').strip() or None
# Build-pack plan/overrides (optional). Provided by the detection step in the
# New Service wizard. Persisted on the Application so the generated Dockerfile
# is reproducible and the Build tab can render the plan.
buildpack_plan = data.get('buildpack_plan') if isinstance(data.get('buildpack_plan'), dict) else None
buildpack_overrides = data.get('buildpack_overrides') if isinstance(data.get('buildpack_overrides'), dict) else None
if not name or len(name) < 2:
return jsonify({'error': 'Service name must be at least 2 characters'}), 400
if source_connection_id:
try:
source_repo = SourceConnectionService.get_authenticated_clone_url(
user_id=user.id,
connection_id=int(source_connection_id),
full_name=repository_full_name,
)
clone_repo_url = source_repo['clone_url']
deploy_repo_url = source_repo['public_url']
except (TypeError, ValueError) as exc:
return jsonify({'error': str(exc)}), 400
else:
clone_repo_url = repo_url
deploy_repo_url = repo_url
if not clone_repo_url:
raise field_required('repo_url')
valid_app_types = ['auto', 'docker', 'flask', 'django', 'php', 'static']
if app_type not in valid_app_types:
return jsonify({'error': f'Invalid app_type. Must be one of: {", ".join(valid_app_types)}'}), 400
valid_build_methods = ['auto', 'dockerfile', 'nixpacks', 'custom']
if build_method not in valid_build_methods:
return jsonify({'error': f'Invalid build_method. Must be one of: {", ".join(valid_build_methods)}'}), 400
if port in ['', None]:
port = None
elif isinstance(port, int) or (isinstance(port, str) and port.isdigit()):
port = int(port)
else:
return jsonify({'error': 'port must be a number'}), 400
# NOT query_active: a tombstone keeps reserving its name (and, below, its
# directory under APPS_DIR). Letting a new app take the name of one sitting
# in the recycle bin would make the restore collide.
clash = app_name_clash_error(
Application.query.filter_by(name=name, server_id=None).first(), name)
if clash:
return jsonify({'error': clash}), 400
try:
app_path = _assert_managed_app_path(name)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
if os.path.exists(app_path):
return jsonify({'error': f'A service directory already exists at {app_path}. Choose another name.'}), 400
clone_result = GitService.clone_repository(app_path, clone_repo_url, branch or None)
if not clone_result.get('success'):
if clone_repo_url != deploy_repo_url and clone_result.get('error'):
clone_result['error'] = clone_result['error'].replace(clone_repo_url, deploy_repo_url)
return jsonify(clone_result), 400
manifest = RepositoryManifestService.analyze_path(app_path)
recommended = manifest.get('recommended') or {}
manifest_strategy = manifest.get('strategy')
detection = BuildService.detect_build_method(app_path)
manifest_app_type_strategies = {
'serverkit',
'docker_compose',
'render',
'railway',
'dockerfile',
'app_json',
}
resolved_app_type = (
(recommended.get('app_type') if manifest_strategy in manifest_app_type_strategies else None)
or _derive_repo_app_type(detection)
) if app_type == 'auto' else app_type
resolved_build_method = build_method
if build_method == 'auto':
resolved_build_method = (
recommended.get('build_method') if manifest_strategy else None
) or detection.get('build_method') or 'auto'
if resolved_build_method == 'custom' and not (custom_build_cmd or recommended.get('custom_build_cmd')):
resolved_build_method = 'auto'
if not port:
port = recommended.get('port')
dockerfile_path = dockerfile_path or recommended.get('dockerfile_path') or 'Dockerfile'
custom_build_cmd = custom_build_cmd or recommended.get('custom_build_cmd')
custom_start_cmd = custom_start_cmd or recommended.get('custom_start_cmd')
# When the build method routes through the build-pack layer, persist the
# (possibly overridden) plan on the Application so the generated Dockerfile is
# reproducible. Detect from the freshly-cloned source if no plan was supplied.
buildpack_type = None
if resolved_build_method in ('nixpacks', 'auto'):
from app.services.buildpack_service import BuildpackService
effective_plan = buildpack_plan or BuildpackService.detect(app_path)
if buildpack_overrides:
effective_plan = BuildpackService.apply_overrides(effective_plan, buildpack_overrides)
buildpack_plan = effective_plan
buildpack_type = (effective_plan or {}).get('builder')
# Optional Project / Environment assignment. Validated against the resolved
# workspace; invalid/foreign ids are silently dropped (non-breaking).
from app.services.workspace_service import WorkspaceService
_ws_id = WorkspaceService.resolve_workspace_id(
user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id'))
if _ws_id is None:
_ws_id = WorkspaceService.ensure_default_workspace().id
project_id, environment_id = _resolve_project_env(data, _ws_id)
# A repository whose manifest is a compose file deploys as that compose
# project — every service it declares (a database next to the web app),
# exactly like an uploaded archive. Building only the Dockerfile it also
# contains used to start the web container without its database. An
# explicit non-compose choice from the caller still wins.
compose_file = None
if (manifest_strategy == 'docker_compose' and app_type in ('auto', 'docker')
and build_method in ('auto', 'dockerfile')):
compose_file = next((m.get('file') for m in manifest.get('manifests') or []
if m.get('type') == 'docker_compose'), None)
managed_by = 'docker_compose' if compose_file else None
if compose_file:
resolved_app_type = 'docker'
app = Application(
name=name,
app_type=resolved_app_type,
status='stopped',
root_path=app_path,
user_id=user.id,
port=port,
compose_file=compose_file,
managed_by=managed_by,
buildpack_type=buildpack_type,
buildpack_plan=json.dumps(buildpack_plan) if buildpack_plan else None,
buildpack_overrides=json.dumps(buildpack_overrides) if buildpack_overrides else None,
ingress_plane=_resolve_ingress_plane(data, resolved_app_type, managed_by),
project_id=project_id,
environment_id=environment_id,
)
try:
created = repository_application_service.finalize_repository_application(
app, user_id=user.id, repo_url=deploy_repo_url, branch=branch or 'main',
auto_deploy=auto_deploy, manifest=manifest,
build_options={
'build_method': resolved_build_method,
'dockerfile_path': dockerfile_path,
'custom_build_cmd': custom_build_cmd,
'custom_start_cmd': custom_start_cmd,
'buildpack_plan': buildpack_plan,
'buildpack_overrides': buildpack_overrides,
},
)
return jsonify({
'message': 'Repository service created',
'app': _attach_deploy_config(app.to_dict(include_linked=True)),
'deploy_job_id': created['deploy_job_id'],
'manifest_import': created['manifest_import'],
'deploy_config': {
'repo_url': _safe_repo_url(deploy_repo_url),
'branch': branch or 'main',
'auto_deploy': auto_deploy,
'webhook_url': created['deploy_result'].get('webhook_url'),
},
'build_config': created['build_config'],
'detection': detection,
'manifest': manifest,
}), 201
except RuntimeError as exc:
# configure_deployment/configure_build failures carry the service's
# own message, written for the caller.
repository_application_service.abort_repository_creation(app)
return jsonify({'error': str(exc)}), 400
except Exception as exc:
# Anything else is a crash: log it with the request id and keep its
# text (paths, SQL, connection strings) out of the response.
repository_application_service.abort_repository_creation(app)
return unexpected_response(exc)
# Fields that point the row at host paths and units root-run code acts on
# (docker compose up, nginx site files, systemctl) — admin-only, as /manual.
_HOST_BINDING_FIELDS = ('root_path', 'compose_file', 'systemd_unit', 'managed_by')
@apps_bp.route('', methods=['POST'])
@jwt_required()
def create_app():
user = get_current_user()
current_user_id = user.id
data = json_object()
if not data:
return jsonify({'error': 'No data provided'}), 400
host_fields = [f for f in _HOST_BINDING_FIELDS if data.get(f)]
if host_fields and not user.is_admin:
raise PermissionDeniedError(f"Only an admin can set {', '.join(host_fields)}")
# The name becomes the nginx vhost name and its file path.
name = _service_slug(data.get('name'))
app_type = data.get('app_type')
if not all([name, app_type]):
return jsonify({'error': 'Missing required fields: name, app_type'}), 400
valid_types = ['php', 'wordpress', 'flask', 'django', 'docker', 'static']
if app_type not in valid_types:
return jsonify({'error': f'Invalid app_type. Must be one of: {", ".join(valid_types)}'}), 400
clash = Application.name_clash_error(name)
if clash:
raise ValidationError(clash)
# Stamp the workspace (#33): the requested one (membership-checked) or the default.
from app.services.workspace_service import WorkspaceService
ws_id = WorkspaceService.resolve_workspace_id(
user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id'))
# Role reconciliation (#33): a workspace 'viewer' member has read-only access to
# the active workspace and may not create resources in it (checked only when a
# workspace context is explicitly active, so no-context behavior is unchanged).
if ws_id is not None and not WorkspaceService.can_write_in_workspace(user, ws_id):
return jsonify({'error': 'You have read-only access to this workspace'}), 403
if ws_id is None:
ws_id = WorkspaceService.ensure_default_workspace().id
project_id, environment_id = _resolve_project_env(data, ws_id)
app = Application(
name=name,
app_type=app_type,
status='stopped',
php_version=data.get('php_version'),
python_version=data.get('python_version'),
port=data.get('port'),
root_path=data.get('root_path'),
docker_image=data.get('docker_image'),
registry_id=data.get('registry_id'),
source=data.get('source') or 'github',
compose_file=data.get('compose_file'),
systemd_unit=data.get('systemd_unit'),
managed_by=data.get('managed_by'),
ingress_plane=_resolve_ingress_plane(data, app_type, data.get('managed_by')),
user_id=current_user_id,
workspace_id=ws_id,
project_id=project_id,
environment_id=environment_id,
)
db.session.add(app)
db.session.commit()
return jsonify({
'message': 'Service created',
'app': app.to_dict()
}), 201
@apps_bp.route('/manual', methods=['POST'])
@jwt_required()
def create_manual_app():
"""Register an app that already exists on the server (manual/local)."""
user = require_admin_user()
data = json_object()
name = _service_slug(data.get('name'))
app_type = (data.get('app_type') or 'docker').strip().lower()
root_path = (data.get('root_path') or '').strip()
compose_file = (data.get('compose_file') or '').strip() or None
systemd_unit = (data.get('systemd_unit') or '').strip() or None
managed_by = (data.get('managed_by') or '').strip().lower() or None
if not name or len(name) < 2:
return jsonify({'error': 'Service name must be at least 2 characters'}), 400
valid_types = ['docker', 'flask', 'django', 'php', 'static', 'wordpress']
if app_type not in valid_types:
return jsonify({'error': f'Invalid app_type. Must be one of: {", ".join(valid_types)}'}), 400
if not root_path:
raise field_required('root_path')
root_path = os.path.abspath(root_path)
if not os.path.isdir(root_path):
return jsonify({'error': f'Path does not exist: {root_path}'}), 400
# Auto-detect managed_by if not provided
if not managed_by:
if compose_file or os.path.exists(os.path.join(root_path, 'docker-compose.yml')):
managed_by = 'docker_compose'
elif systemd_unit:
managed_by = 'systemd'
else:
managed_by = 'docker_compose'
if managed_by not in ('docker_compose', 'systemd'):
return jsonify({'error': "managed_by must be 'docker_compose' or 'systemd'"}), 400
if managed_by == 'docker_compose' and not compose_file:
if not os.path.exists(os.path.join(root_path, 'docker-compose.yml')):
return jsonify({'error': 'docker-compose.yml not found and no compose_file provided'}), 400
compose_file = 'docker-compose.yml'
# NOT query_active: a tombstone keeps reserving its name so a restore can't
# collide with an app created in the meantime.
clash = app_name_clash_error(
Application.query.filter_by(name=name).first(), name)
if clash:
return jsonify({'error': clash}), 400
from app.services.workspace_service import WorkspaceService
ws_id = WorkspaceService.resolve_workspace_id(
user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id'))
if ws_id is None:
ws_id = WorkspaceService.ensure_default_workspace().id
project_id, environment_id = _resolve_project_env(data, ws_id)
app = Application(
name=name,
app_type=app_type,
status='stopped',
source='manual',
root_path=root_path,
compose_file=compose_file,
systemd_unit=systemd_unit,
managed_by=managed_by,
ingress_plane=_resolve_ingress_plane(data, app_type, managed_by),
user_id=user.id,
workspace_id=ws_id,
project_id=project_id,
environment_id=environment_id,
)
db.session.add(app)
db.session.commit()
# Sync status immediately so the UI reflects reality
_sync_manual_app_status(app)
return jsonify({
'message': 'Manual service registered',
'app': _attach_deploy_config(app.to_dict(include_linked=True))
}), 201
@apps_bp.route('/upload', methods=['POST'])
@jwt_required()
def upload_app_archive():
"""Create or update an app by uploading a zip archive."""
user = require_admin_user()
if 'file' not in request.files:
return jsonify({'error': 'No file provided'}), 400
uploaded = request.files['file']
if not uploaded or uploaded.filename == '':
return jsonify({'error': 'No file selected'}), 400
name = _service_slug(request.form.get('name') or uploaded.filename.rsplit('.', 1)[0])
app_type = (request.form.get('app_type') or 'auto').strip().lower()
auto_deploy = request.form.get('auto_deploy', 'true').lower() == 'true'
if not name or len(name) < 2:
return jsonify({'error': 'Service name must be at least 2 characters'}), 400
valid_types = ['auto', 'docker', 'flask', 'django', 'php', 'static', 'node']
if app_type not in valid_types:
return jsonify({'error': f'Invalid app_type. Must be one of: {", ".join(valid_types)}'}), 400
from app.services.workspace_service import WorkspaceService
ws_id = WorkspaceService.resolve_workspace_id(
user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id'))
if ws_id is None:
ws_id = WorkspaceService.ensure_default_workspace().id
# The name lookup serves two purposes and a tombstone answers them
# differently. It still RESERVES the name (so the unfiltered fallback), but
# it is never the row an upload updates in place: that path extracts a new
# version over the app's storage and compose_up's it, which would resurrect
# an app the user deleted. Ask for the live row first so a tombstone can
# never shadow it; a tombstone-only hit falls through to "already exists".
existing = (Application.query_active().filter_by(name=name).first()
or Application.query.filter_by(name=name).first())
is_update = existing is not None and existing.is_active and existing.source == 'upload'
temp_dir = os.path.join(paths.SERVERKIT_CACHE_DIR, 'uploads')
os.makedirs(temp_dir, exist_ok=True)
timestamp = datetime.utcnow().strftime('%Y%m%d-%H%M%S-%f')
zippath = os.path.join(temp_dir, f'{name}-{timestamp}.zip')
uploaded.save(zippath)
validation = validate_zip(zippath)
if not validation.get('success'):
os.remove(zippath)
return jsonify({'error': validation.get('error', 'Invalid zip')}), 400
try:
app_dir = ensure_app_dirs(name)
if is_update:
app = existing
new_version = (app.version or 0) + 1
backup_path = backup_current(app_dir)
else:
if existing:
os.remove(zippath)
return jsonify({'error': app_name_clash_error(existing, name)}), 400
new_version = 1
version_dir = extract_version(app_dir, zippath, new_version)
upload_archive_path = save_upload_zip(app_dir, zippath, new_version)
if is_update:
preserve_existing_env(os.path.join(app_dir, 'current'), version_dir)
current_dir = switch_current_version(app_dir, new_version)
if app_type == 'auto':
detected = detect_app_type(current_dir)
else:
detected = app_type
if detected == 'node':
detected = 'docker'
compose_file = None
if detected == 'docker':
if os.path.exists(os.path.join(current_dir, 'docker-compose.yml')):
compose_file = 'docker-compose.yml'
elif os.path.exists(os.path.join(current_dir, 'Dockerfile')):
compose_file = None
if is_update:
app.app_type = detected
app.compose_file = compose_file
app.version = new_version
app.upload_path = upload_archive_path
app.root_path = current_dir
app.updated_at = datetime.utcnow()
else:
project_id, environment_id = _resolve_project_env(request.form, ws_id)
app = Application(
name=name,
app_type=detected,
status='stopped',
source='upload',
root_path=current_dir,
compose_file=compose_file,
managed_by='docker_compose' if detected == 'docker' else None,
ingress_plane=_resolve_ingress_plane(
request.form, detected,
'docker_compose' if detected == 'docker' else None,
),
version=new_version,
upload_path=upload_archive_path,
user_id=user.id,
workspace_id=ws_id,
project_id=project_id,
environment_id=environment_id,
)
db.session.add(app)
db.session.commit()
deploy_result = None
if auto_deploy and detected == 'docker' and compose_file:
deploy_result = DockerService.compose_up(current_dir, detach=True, build=True, compose_file=compose_file)
if deploy_result.get('success'):
app.status = 'running'
app.last_deployed_at = datetime.utcnow()
db.session.commit()
os.remove(zippath)
return jsonify({
'message': 'Upload service updated' if is_update else 'Upload service created',
'app': _attach_deploy_config(app.to_dict(include_linked=True)),
'version': new_version,
'detected_app_type': detected,
'deploy_result': deploy_result,
}), 201 if not is_update else 200
except Exception as exc:
try:
os.remove(zippath)
except Exception:
pass
if isinstance(exc, ValueError):
# upload_service's validation errors are written for the caller.
return jsonify({'error': str(exc)}), 400
# Anything else is a crash: logged with the request id, its text
# (paths, SQL) kept out of the response.
return unexpected_response(exc)
@apps_bp.route('/<int:app_id>/versions', methods=['GET'])
@jwt_required()
def get_app_versions(app_id):
"""List versions for an uploaded app."""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_access_app(user, app):
raise permission_denied()
if app.source != 'upload':
return jsonify({'versions': [], 'current': None}), 200
try:
app_dir = get_app_storage_dir(app.name)
versions = list_versions(app_dir)
current = get_current_version(app_dir)
return jsonify({'versions': versions, 'current': current}), 200
except ValueError as e:
return jsonify({'error': str(e)}), 400
except Exception as e:
return unexpected_response(e)
@apps_bp.route('/<int:app_id>/rollback', methods=['POST'])
@jwt_required()
def rollback_app_version(app_id):
"""Roll an uploaded app back to a previous version."""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_edit_app(user, app):
raise permission_denied()
if app.source != 'upload':
return jsonify({'error': 'Rollback is only available for services deployed by upload'}), 400
data = json_object()
target = data.get('version')
if target is None:
raise field_required('version')
try:
target = int(target)
except (TypeError, ValueError):
return jsonify({'error': 'version must be an integer'}), 400
try:
app_dir = get_app_storage_dir(app.name)
current_dir = switch_current_version(app_dir, target)
app.root_path = current_dir
app.version = target
app.updated_at = datetime.utcnow()
if app.app_type == 'docker' and app.compose_file:
result = DockerService.compose_up(current_dir, detach=True, build=True, compose_file=app.compose_file)
if result.get('success'):
app.status = 'running'
app.last_deployed_at = datetime.utcnow()
else:
return jsonify({'error': result.get('error', 'Failed to start service'), 'app': app.to_dict()}), 400
db.session.commit()
return jsonify({
'message': f'Rolled back to version {target}',
'app': _attach_deploy_config(app.to_dict(include_linked=True))
}), 200
except ValueError as e:
# An unknown version is the caller's to fix.
return jsonify({'error': str(e)}), 400
except Exception as e:
return unexpected_response(e)
@apps_bp.route('/<int:app_id>', methods=['PUT'])
@jwt_required()
def update_app(app_id):
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_edit_app(user, app):
raise permission_denied()
data = json_object()
# Validate before anything is assigned, so a bad setting leaves the app
# untouched rather than half-updated in the session.
host_fields = [f for f in _HOST_BINDING_FIELDS if f in data]
if host_fields and not user.is_admin:
raise PermissionDeniedError(f"Only an admin can change {', '.join(host_fields)}")
if 'name' in data and data['name'] != app.name:
if not data['name'] or _service_slug(data['name']) != data['name']:
raise ValidationError('Service name must be lowercase letters, digits and hyphens')
clash = Application.name_clash_error(data['name'])
if clash:
raise ValidationError(clash)
if 'deploy_settings' in data:
from app.services import deploy_settings
_, error = deploy_settings.validate(data['deploy_settings'])
if error:
raise ValidationError(error)
if 'name' in data:
app.name = data['name']
if 'status' in data:
app.status = data['status']
if 'php_version' in data:
app.php_version = data['php_version']
if 'python_version' in data:
app.python_version = data['python_version']
if 'port' in data:
app.port = data['port']
if 'healthcheck_path' in data:
hc = (data['healthcheck_path'] or '').strip()
app.healthcheck_path = hc or None
if 'deploy_settings' in data:
from app.services import deploy_settings
deploy_settings.update(app, data['deploy_settings'])
if 'root_path' in data:
app.root_path = data['root_path']
if 'docker_image' in data:
app.docker_image = data['docker_image']
if 'registry_id' in data:
# Accept null to unbind; otherwise a valid registry id.
app.registry_id = data['registry_id'] or None
if 'source' in data:
app.source = data['source']
if 'compose_file' in data:
app.compose_file = data['compose_file']
if 'systemd_unit' in data:
app.systemd_unit = data['systemd_unit']
if 'managed_by' in data:
app.managed_by = data['managed_by']
db.session.commit()
return jsonify({
'message': 'Service updated',
'app': app.to_dict()
}), 200
@apps_bp.route('/<int:app_id>', methods=['DELETE'])
@jwt_required()
def delete_app(app_id):
"""Soft-delete an app: stop it serving, keep the record and the data.
What used to happen here — compose_down(volumes=True) and rmtree of the
uploaded source — has moved to the PURGE hook. Doing it here while keeping
a tombstone would put a Restore button next to a row whose data had already
been destroyed. The reversible half (containers down, vhost removed,
firewall closed, cron suspended) still happens now; see
services/application_restore.py.
`?purge=true` is the escape hatch for "I do not want this in the bin",
and it runs the irreversible half immediately.
"""
from app.services import application_restore, recycle_bin_service
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
# query_active: deleting something already in the bin is a 404, not a
# second delete that would re-stamp deleted_at and restart the retention
# clock on a row the user cannot even see on this page.
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not user.is_admin and app.user_id != user.id:
raise permission_denied()
purge_now = str(request.args.get('purge', '')).lower() in ('1', 'true', 'yes')
cleanup_results = application_restore.suspend_application(app, user_id=current_user_id)
if purge_now:
# Carry the caller's data intent into the purge hook. Without this a
# `?purge=true&remove_data=false` -- and every database-engine uninstall,
# which defaults to preserving its volumes -- would lose the data the
# flag exists to protect.
app._purge_remove_data = _remove_data_flag(app)
ok, warning = recycle_bin_service.purge('application', app.id)
return jsonify({
'message': 'Service deleted permanently',
'purged': ok,
'cleanup': cleanup_results,
'warning': warning,
}), 200
return jsonify({
'message': 'Service moved to the recycle bin',
'recycle_bin': True,
'cleanup': cleanup_results,
}), 200
@apps_bp.route('/<int:app_id>/start', methods=['POST'])
@jwt_required()
def start_app(app_id):
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_edit_app(user, app):
raise permission_denied()
try:
application_lifecycle_service.start_application(app, user_id=current_user_id)
except application_lifecycle_service.ApplicationLifecycleError as exc:
return jsonify({'error': str(exc)}), 400
return jsonify({
'message': 'Service started',
'app': app.to_dict()
}), 200
@apps_bp.route('/<int:app_id>/image-update/apply', methods=['POST'])
@jwt_required()
def apply_image_update(app_id):
"""Pull the newest image for a compose-managed app and recreate it."""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_edit_app(user, app):
raise permission_denied()
# A slot app never recreates its live stack in place: the newest images
# go through a slot deploy (pulled in its preflight, gated, switched).
from app.services.slot_deploy_service import SlotDeployService
if SlotDeployService.is_compose_slot_app(app):
from app.services.deployment_job_service import DeploymentJobService
queued = DeploymentJobService.enqueue_app_deploy(app, user_id=current_user_id,
trigger='image_update')
if not queued.get('success'):
from app.exceptions import ConflictError
raise ConflictError(queued.get('error') or 'Failed to queue the update')
return jsonify({'message': 'Image update queued as a slot deploy',
'deploy_job_id': queued['job_id'], 'app': app.to_dict()}), 202
# Auto-apply is only safe for compose-managed apps; recreating a standalone
# container would need its full run spec, which we don't store.
if app.app_type != 'docker' or not app.root_path or not app.compose_file:
return jsonify({'error': 'Automatic update is only available for Docker Compose services. '
'Pull and recreate this container manually.'}), 400
# Pull the newest images for the project, then recreate changed containers.
if app.server_id:
pull = RemoteDockerService.compose_pull(app.server_id, _compose_target(app), user_id=current_user_id)
if not pull.get('success') or _agent_result_failed(pull):
return jsonify({'error': _agent_result_error(pull, 'Failed to pull image')}), 400
up = RemoteDockerService.compose_up(app.server_id, _compose_target(app), detach=True, user_id=current_user_id)
else:
# Authenticate a bound private registry for the pull; always log out after.
_registry = ContainerRegistryService.login_for_app(app)
try:
pull = DockerService.compose_pull(app.root_path, compose_file=_local_compose_file(app))
if not pull.get('success'):
return jsonify({'error': pull.get('error', 'Failed to pull image')}), 400
up = DockerService.compose_up(app.root_path, detach=True, compose_file=_local_compose_file(app))
finally:
ContainerRegistryService.logout_for_app(_registry)
if not up.get('success') or _agent_result_failed(up):
return jsonify({'error': _agent_result_error(up, 'Failed to recreate containers')}), 400
app.status = 'running'
app.last_deployed_at = datetime.utcnow()
db.session.commit()
# Containers were recreated — their ids changed, so any cached aggregate is
# describing containers that no longer exist.
container_status_service.invalidate(app_id)
# Refresh the update badge now that we're on the new image.
ImageUpdateService.check_application(app_id)
return jsonify({
'message': 'Image updated and containers recreated',
'app': app.to_dict(),
}), 200
@apps_bp.route('/<int:app_id>/sleep-policy', methods=['GET'])
@jwt_required()
def get_sleep_policy(app_id):
user = get_current_user()
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_access_app(user, app):
raise permission_denied()
return jsonify(ContainerSleepService.get_or_create_policy(app_id).to_dict())
@apps_bp.route('/<int:app_id>/sleep-policy', methods=['PUT'])
@jwt_required()
def update_sleep_policy(app_id):
user = get_current_user()
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_edit_app(user, app):
raise permission_denied()
data = json_object()
policy = ContainerSleepService.set_policy(
app_id, enabled=data.get('enabled'), idle_timeout_minutes=data.get('idle_timeout_minutes'))
return jsonify(policy.to_dict())
@apps_bp.route('/<int:app_id>/sleep', methods=['POST'])
@jwt_required()
def sleep_app(app_id):
user = get_current_user()
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_edit_app(user, app):
raise permission_denied()
result = ContainerSleepService.sleep_app(app_id)
if not result.get('success'):
return jsonify({'error': result['error']}), 400
return jsonify({'message': 'Service asleep', 'policy': result['policy'], 'app': app.to_dict()})
@apps_bp.route('/<int:app_id>/wake', methods=['POST'])
@jwt_required()
def wake_app(app_id):
user = get_current_user()
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_edit_app(user, app):
raise permission_denied()
result = ContainerSleepService.wake_app(app_id)
if not result.get('success'):
return jsonify({'error': result['error']}), 400
return jsonify({'message': 'Service awake', 'policy': result['policy'], 'app': app.to_dict()})
@apps_bp.route('/sweep-idle', methods=['POST'])
@jwt_required()
def sweep_idle_apps():
"""Sleep all enabled apps that have been idle past their timeout. Intended
to be hit periodically (cron or a scheduler)."""
user = get_current_user()
if not (user and user.is_admin):
return jsonify({'error': 'Admin access required'}), 403
return jsonify(ContainerSleepService.sweep_idle())
# ==================== RESOURCE LIMITS (task #23) ====================
def _parse_docker_stats(stats):
"""Normalize a `docker stats --format json` row to a small usage dict.
Returns None when stats are unavailable (stopped app, no Docker on the
host — e.g. Windows dev).
"""
if not stats:
return None
def _pct(value):
try:
return float(str(value).rstrip('%'))
except (TypeError, ValueError):
return None
usage = {
'cpu_percent': _pct(stats.get('CPUPerc')),
'memory_percent': _pct(stats.get('MemPerc')),
'memory_usage': None,
'memory_limit': None,
}
mem = stats.get('MemUsage') or ''
if '/' in mem:
used, limit = mem.split('/', 1)
usage['memory_usage'] = used.strip()
usage['memory_limit'] = limit.strip()
return usage
@apps_bp.route('/<int:app_id>/resources', methods=['GET'])
@jwt_required()
def get_app_resources(app_id):
"""The app's configured CPU/memory limits plus best-effort live usage."""
user = get_current_user()
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_access_app(user, app):
raise permission_denied()
usage = None
if app.status == 'running' and not app.server_id:
try:
container_id = DockerService.get_app_container_id(app)
if container_id:
usage = _parse_docker_stats(DockerService.get_container_stats(container_id))
except Exception:
usage = None # live stats are best-effort
return jsonify({
'cpu_limit': app.cpu_limit,
'memory_limit': app.memory_limit,
'usage': usage,
}), 200
@apps_bp.route('/<int:app_id>/resources', methods=['PUT'])
@jwt_required()
def update_app_resources(app_id):
"""Set the app's CPU/memory limits; re-apply live when possible.
Empty/null clears a limit. For a locally managed compose app that is
running, `compose up -d` regenerates the ServerKit override (which carries
the limits) and recreates changed containers; otherwise the limits are
saved and take effect on the next restart/redeploy.
"""
user = get_current_user()
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_edit_app(user, app):
raise permission_denied()
data = json_object()
try:
if 'cpu_limit' in data:
app.cpu_limit = DockerService.validate_cpu_limit(data['cpu_limit'])
if 'memory_limit' in data:
app.memory_limit = DockerService.validate_memory_limit(data['memory_limit'])
except ValueError as e:
return jsonify({'error': str(e)}), 400
db.session.commit()
applied = False
note = None
is_local_compose = (
not app.server_id and app.root_path
and (app.app_type == 'docker' or app.managed_by == 'docker_compose')
)
if app.status == 'running':
if is_local_compose:
result = DockerService.compose_up(app.root_path, compose_file=_local_compose_file(app))
applied = bool(result.get('success'))
if not applied:
note = 'restart required'
else:
note = 'restart required'
response = {
'message': 'Resource limits saved',
'cpu_limit': app.cpu_limit,
'memory_limit': app.memory_limit,
'applied': applied,
}
if note:
response['note'] = note
return jsonify(response), 200
# ==================== MICRO-CACHE (task #21) ====================
@apps_bp.route('/<int:app_id>/micro-cache', methods=['PUT'])
@jwt_required()
def set_micro_cache(app_id):
"""Toggle the per-site nginx micro-cache and republish the vhost.
The flag flows through the same kwargs pipeline drift detection uses
(SiteDomainService.app_vhost_kwargs -> NginxService.render_site_config),
so an enabled cache is part of the expected vhost and never reads as
config drift. When the app has no domains yet, the flag is save-only and
takes effect on first publish.
"""
user = get_current_user()
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_edit_app(user, app):
raise permission_denied()
data = json_object()
if 'enabled' not in data:
return jsonify({'error': "'enabled' is required"}), 400
from app.services.nginx_service import NginxService
if 'ttl' in data:
ttl = data['ttl']
if ttl is not None and (isinstance(ttl, bool) or not isinstance(ttl, int)
or not 1 <= ttl <= NginxService.MICROCACHE_TTL_MAX):
from app.exceptions import ValidationError
raise ValidationError("'ttl' must be whole seconds from 1 to "
f'{NginxService.MICROCACHE_TTL_MAX}')
app.micro_cache_ttl = ttl
app.micro_cache_enabled = bool(data['enabled'])
db.session.commit()
applied = False
note = None
warning = None
if app.domains:
# Reuse the exact publish path (render + write + enable + reload).
from app.services.site_domain_service import SiteDomainService
result = SiteDomainService.write_app_vhost(app)
warning = result.get('warning')
applied = result.get('nginx') is not None and not warning
else:
note = ('Saved. The micro-cache takes effect when this service is '
'published at a domain.')
response = {
'message': f"Micro-cache {'enabled' if app.micro_cache_enabled else 'disabled'}",
'micro_cache_enabled': bool(app.micro_cache_enabled),
'micro_cache_ttl': app.micro_cache_ttl,
'applied': applied,
}
if note:
response['note'] = note
if warning:
response['warning'] = warning
return jsonify(response), 200
@apps_bp.route('/<int:app_id>/micro-cache/purge', methods=['POST'])
@jwt_required()
def purge_micro_cache(app_id):
"""Clear this site's cached pages.
The zone is shared, but the cache key carries the host, so only entries
for this app's domains are removed. No nginx reload is needed.
"""
user = get_current_user()
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_edit_app(user, app):
raise permission_denied()
hosts = [d.name for d in app.live_domains if d.name]
if not hosts:
return jsonify({'message': 'Nothing cached: this service has no domain yet',
'purged': 0}), 200
from app.services.nginx_service import NginxService
result = NginxService.purge_micro_cache(hosts)
if not result.get('success'):
return jsonify({'error': result.get('error', 'Purge failed')}), 500
return jsonify({'message': result.get('message', 'Micro-cache cleared'),
'purged': result.get('purged', 0)}), 200
@apps_bp.route('/<int:app_id>/stop', methods=['POST'])
@jwt_required()
def stop_app(app_id):
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_edit_app(user, app):
raise permission_denied()
try:
application_lifecycle_service.stop_application(app, user_id=current_user_id)
except application_lifecycle_service.ApplicationLifecycleError as exc:
return jsonify({'error': str(exc)}), 400
return jsonify({
'message': 'Service stopped',
'app': app.to_dict()
}), 200
@apps_bp.route('/<int:app_id>/restart', methods=['POST'])
@jwt_required()
def restart_app(app_id):
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_edit_app(user, app):
raise permission_denied()
try:
application_lifecycle_service.restart_application(app, user_id=current_user_id)
except application_lifecycle_service.ApplicationLifecycleError as exc:
return jsonify({'error': str(exc)}), 400
return jsonify({
'message': 'Service restarted',
'app': app.to_dict()
}), 200
@apps_bp.route('/<int:app_id>/related', methods=['GET'])
@jwt_required()
def get_app_related(app_id):
"""Domains, databases, backup status and deploy count for one app.
The Overview tab has always called this; it was never implemented, so the
request 404'd, the client swallowed it, and the card sat on "Loading…"
forever. Each section is gathered independently — a missing table or a
disabled feature degrades that section to empty rather than failing the
whole card.
"""
user = get_current_user()
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_access_app(user, app):
raise permission_denied()
related = {'domains': [], 'databases': [], 'backup': None, 'deployments': {'count': 0}}
try:
from app.models.domain import Domain
related['domains'] = [
{'id': d.id, 'name': d.name, 'ssl_enabled': bool(getattr(d, 'ssl_enabled', False))}
for d in Domain.query_active().filter_by(application_id=app.id).all()
]
except Exception:
current_app.logger.warning('related: domains failed', exc_info=True)
try:
from app.models.managed_database import ManagedDatabase
related['databases'] = [
{'id': m.id, 'name': m.name, 'engine': getattr(m, 'engine', '')}
for m in ManagedDatabase.query_active().filter_by(owner_application_id=app.id).all()
]
except Exception:
current_app.logger.warning('related: databases failed', exc_info=True)
try:
from app.models.backup_policy import BackupPolicy
policy = BackupPolicy.query.filter_by(target_type='application', target_id=app.id).first()
if policy:
related['backup'] = {
'enabled': bool(policy.enabled),
'frequency': policy.schedule_cron,
'last_status': policy.last_status,
}
except Exception:
current_app.logger.warning('related: backup failed', exc_info=True)
try:
from app.models.deployment import Deployment
related['deployments'] = {'count': Deployment.query.filter_by(app_id=app.id).count()}
except Exception:
current_app.logger.warning('related: deployments failed', exc_info=True)
return jsonify(related), 200
@apps_bp.route('/<int:app_id>/logs', methods=['GET'])
@jwt_required()
def get_app_logs(app_id):
"""Get logs for a specific application."""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_access_app(user, app):
raise permission_denied()
lines = request.args.get('lines', 100, type=int)
log_type = request.args.get('type', 'all')
# A recorded container is the authoritative runtime for single-container
# deployments. Repositories may still contain an unrelated compose file,
# so file presence alone must not redirect their logs through compose.
if app.app_type == 'docker' and app.container_id and not app.server_id:
if DockerService.get_container(app.container_id):
result = DockerService.get_container_logs(
app.container_id, tail=lines, timestamps=False
)
if result.get('success'):
return jsonify(result), 200
# Preserve the legacy Docker Compose path when there is no live recorded
# container (including old records that predate container_id tracking).
if app.app_type == 'docker' and app.root_path:
if app.server_id:
result = RemoteDockerService.compose_logs(
app.server_id,
_compose_target(app),
tail=lines,
user_id=current_user_id
)
return jsonify(result.get('data') if result.get('success') else result), 200 if result.get('success') else 400
result = LogService.get_docker_app_logs(
app.name, app.root_path, lines, compose_file=_local_compose_file(app)
)
return jsonify(result), 200 if result.get('success') else 400
# For other apps, get nginx logs
result = LogService.get_app_logs(app.name, log_type, lines)
return jsonify(result), 200 if result.get('success') else 400
@apps_bp.route('/<int:app_id>/container-logs', methods=['GET'])
@jwt_required()
def get_container_logs(app_id):
"""Get container logs for a Docker application.
Query params:
- tail: Number of lines from end (default: 100, max: 10000)
- since: ISO timestamp or duration (e.g., '10m', '1h', '2024-01-01T00:00:00Z')
- timestamps: Include timestamps (default: true)
- format: Output format - 'raw' or 'json' (default: 'raw')
- service: Specific service/container name for compose apps (optional)
Returns:
{
"success": true,
"logs": "...", // Raw format
"lines": [...] // JSON format with parsed lines
"container_id": "...",
"container_name": "...",
"app_id": 1,
"containers": [...] // Available containers for compose apps
}
"""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_access_app(user, app):
raise permission_denied()
# Parse query parameters
tail = request.args.get('tail', 100, type=int)
tail = min(tail, 10000) # Cap at 10000 lines
since = request.args.get('since')
timestamps = request.args.get('timestamps', 'true').lower() == 'true'
output_format = request.args.get('format', 'raw')
service = request.args.get('service')
# Get all available containers for this app
all_containers = DockerService.get_all_app_containers(app)
# Determine which container to get logs from
container_id = None
container_name = None
if service:
# Find specific service container
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
if not container_id:
return jsonify({
'error': f'Service "{service}" not found',
'available_services': [c.get('service') or c.get('name') for c in all_containers]
}), 404
else:
# Get main container
container_id = DockerService.get_app_container_id(app)
if all_containers:
container_name = all_containers[0].get('name')
if not container_id:
return jsonify({
'error': 'No container found for this service',
'hint': 'Start the service, then try again'
}), 404
# Check container state
container_state = DockerService.get_container_state(container_id)
if not container_state:
return jsonify({
'error': 'Container not found or no longer exists'
}), 404
# Get logs
result = DockerService.get_container_logs(
container_id,
tail=tail,
since=since,
timestamps=timestamps
)
if not result.get('success'):
return jsonify({
'error': result.get('error', 'Failed to fetch logs')
}), 400
logs = result.get('logs', '')
response = {
'success': True,
'app_id': app_id,
'container_id': container_id,
'container_name': container_name,
'container_state': container_state,
'containers': all_containers
}
if output_format == 'json':
response['lines'] = DockerService.parse_logs_to_lines(logs)
else:
response['logs'] = logs
return jsonify(response), 200
@apps_bp.route('/<int:app_id>/containers', methods=['GET'])
@jwt_required()
def get_app_containers(app_id):
"""Get list of containers for a Docker application.
Useful for compose apps with multiple services.
Returns:
{
"success": true,
"app_id": 1,
"containers": [
{"id": "abc123", "name": "app-web", "service": "web", "state": "running"},
{"id": "def456", "name": "app-db", "service": "db", "state": "running"}
]
}
"""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_access_app(user, app):
raise permission_denied()
containers = DockerService.get_all_app_containers(app)
return jsonify({
'success': True,
'app_id': app_id,
'containers': containers
}), 200
@apps_bp.route('/<int:app_id>/status', methods=['GET'])
@jwt_required()
def get_app_status(app_id):
"""Get real-time status for a Docker application."""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query_active().filter_by(id=app_id).first()
if not app:
raise not_found('service')
if not _can_access_app(user, app):
raise permission_denied()
# Manual / local apps: ask the real runtime
if app.source == 'manual':
_sync_manual_app_status(app)
if app.managed_by == 'systemd' and app.systemd_unit:
unit_status = ProcessService.get_systemd_unit_status(app.systemd_unit)
return jsonify({
'status': app.status,
'unit_status': unit_status.get('status'),
'active': unit_status.get('active'),
'containers': [],
'running': 1 if app.status == 'running' else 0,
'total': 1,
'port': app.port,
'port_accessible': None,
}), 200
if app.app_type == 'docker' and app.root_path:
# Get container status from Docker
if app.server_id:
result = RemoteDockerService.compose_ps(
app.server_id,
_compose_target(app),
user_id=current_user_id
)
if not result.get('success'):
return jsonify(result), 503 if result.get('code') == 'AGENT_OFFLINE' else 400
containers = result.get('data', [])
else:
containers = DockerService.compose_ps(
app.root_path,
compose_file=_local_compose_file(app)
)
# Determine overall status
running_count = sum(1 for c in containers if c.get('Status', c.get('status', '')).startswith('Up'))
total_count = len(containers)
if total_count == 0:
actual_status = 'stopped'
elif running_count == total_count:
actual_status = 'running'
elif running_count > 0:
actual_status = 'partial'
else:
actual_status = 'stopped'
# Update DB if status changed
if app.status != actual_status and actual_status in ['running', 'stopped']:
app.status = actual_status
db.session.commit()
# Check port accessibility
port_status = None
if app.port and not app.server_id:
port_status = DockerService.check_port_accessible(app.port)
return jsonify({
'status': actual_status,
'containers': containers,
'running': running_count,
'total': total_count,
'port': app.port,
'port_accessible': port_status.get('accessible') if port_status else None
}), 200
# Non-Docker apps
port_status = None
if app.port:
port_status = DockerService.check_port_accessible(app.port)
return jsonify({
'status': app.status,
'containers': [],
'running': 1 if app.status == 'running' else 0,
'total': 1,
'port': app.port,
'port_accessible': port_status.get('accessible') if port_status else None
}), 200
# ==================== BACKUP PROTECTION (policy + runs) ====================
@apps_bp.route('/<int:app_id>/backup-policy', methods=['GET'])
@jwt_required()
def get_app_backup_policy(app_id):
"""Return the protection policy + status for an app (creating a default)."""
app = require_app(app_id)
policy = BackupPolicyService.get_or_create_policy('application', app_id)
return jsonify(BackupPolicyService.serialize_policy_view(policy))
@apps_bp.route('/<int:app_id>/backup-policy', methods=['PUT'])
@jwt_required()
def update_app_backup_policy(app_id):
"""Update the protection policy and re-sync its schedule."""
app = require_app(app_id, write=True)
policy = BackupPolicyService.get_or_create_policy('application', app_id)
try:
BackupPolicyService.update_policy(policy, json_object())
except BackupPolicyError as e:
return jsonify({'error': str(e)}), 400
return jsonify(BackupPolicyService.serialize_policy_view(policy))
@apps_bp.route('/<int:app_id>/backups', methods=['POST'])
@jwt_required()
def trigger_app_backup(app_id):
"""Enqueue a one-off backup for the app."""
app = require_app(app_id, write=True)
policy = BackupPolicyService.get_or_create_policy('application', app_id)
try:
job = BackupPolicyService.run_policy_now(policy, manual=True)
except BackupPolicyError as e:
return jsonify({'error': str(e)}), 409
return jsonify({'success': True, 'job_id': job.id}), 202
@apps_bp.route('/<int:app_id>/backups', methods=['GET'])
@jwt_required()
def list_app_backups(app_id):
"""List backup runs for the app."""
app = require_app(app_id)
policy = BackupPolicyService.get_or_create_policy('application', app_id)
return jsonify({'runs': BackupPolicyService.list_runs(policy)})
@apps_bp.route('/<int:app_id>/backups/<int:run_id>/restore', methods=['POST'])
@jwt_required()
def restore_app_backup(app_id, run_id):
"""Enqueue a restore from a specific backup run."""
app = require_app(app_id, write=True)
policy = BackupPolicyService.get_or_create_policy('application', app_id)
try:
job = BackupPolicyService.request_restore(policy, run_id, json_object())
except BackupPolicyError as e:
return jsonify({'error': str(e)}), 400
return jsonify({'success': True, 'job_id': job.id}), 202
@apps_bp.route('/<int:app_id>/backups/<int:run_id>/verify', methods=['POST'])
@jwt_required()
def verify_app_backup(app_id, run_id):
"""Verify the remote copy of a backup run."""
app = require_app(app_id, write=True)
policy = BackupPolicyService.get_or_create_policy('application', app_id)
try:
result = BackupPolicyService.verify_run(policy, run_id)
except BackupPolicyError as e:
return jsonify({'error': str(e)}), 400
return jsonify(result)
@apps_bp.route('/<int:app_id>/backups/<int:run_id>', methods=['DELETE'])
@jwt_required()
def delete_app_backup(app_id, run_id):
"""Delete a backup run (local + remote + record)."""
app = require_app(app_id, write=True)
policy = BackupPolicyService.get_or_create_policy('application', app_id)
try:
BackupPolicyService.delete_run(policy, run_id)
except BackupPolicyError as e:
return jsonify({'error': str(e)}), 400
return jsonify({'success': True})
# --------------------------------------------------------------------------- #
# WordPress reachable through the generic application surface.
# A WordPress site is a 1:1 extension of an Application (WordPressSite.
# application_id); the WordPressSite record — not app_type — is the source of
# truth (see wordpress.py:_is_wp_app). These additive routes expose the WP
# linkage and its DB snapshots under /apps/:id without changing app_type, so the
# docker-compose code paths that key on app_type=='docker' keep working.
# --------------------------------------------------------------------------- #
def _wp_site_for_app(app):
from app.models.wordpress_site import WordPressSite
return WordPressSite.query.filter_by(application_id=app.id).first()
@apps_bp.route('/<int:app_id>/wordpress', methods=['GET'])
@jwt_required()
def app_wordpress_info(app_id):
"""Whether this app is a WordPress site, and the linked site id. Lets the
generic Services surface discover the WordPress extension."""
app = require_app(app_id)
site = _wp_site_for_app(app)
if not site:
return jsonify({'is_wordpress': False, 'site_id': None}), 200
return jsonify({
'is_wordpress': True,
'site_id': site.id,
'environment_type': site.environment_type,
'is_production': site.is_production,
'wp_version': site.wp_version,
}), 200
@apps_bp.route('/<int:app_id>/db-snapshots', methods=['GET'])
@jwt_required()
def list_app_db_snapshots(app_id):
"""List the WordPress DB snapshots for an app (unified app-id surface)."""
app = require_app(app_id)
from app.models.wordpress_site import DatabaseSnapshot
site = _wp_site_for_app(app)
if not site:
return jsonify({'error': 'This service is not a WordPress site'}), 400
snaps = (DatabaseSnapshot.query.filter_by(site_id=site.id)
.order_by(DatabaseSnapshot.created_at.desc()).all())
return jsonify({'snapshots': [s.to_dict() for s in snaps], 'total': len(snaps)}), 200
@apps_bp.route('/<int:app_id>/db-snapshots', methods=['POST'])
@jwt_required()
def create_app_db_snapshot(app_id):
"""Create a WordPress DB snapshot for an app."""
app = require_app(app_id, write=True)
from app.models.wordpress_site import DatabaseSnapshot
from app.services.db_sync_service import DatabaseSyncService
site = _wp_site_for_app(app)
if not site:
return jsonify({'error': 'This service is not a WordPress site'}), 400
# Resolve the extension only once we know this IS a WordPress site — with
# serverkit-wordpress uninstalled a non-WP app must get the 400, not an
# import error (graceful absence). A WP SITE with the
# extension missing/disabled gets a clean 503, not an uncaught
# WordPressExtensionMissingError → 500 (audit F3).
from app.services.wordpress_bridge import (
WordPressExtensionMissingError, wordpress_env_service,
)
try:
WordPressEnvService = wordpress_env_service()
except WordPressExtensionMissingError as e:
return jsonify({'error': str(e)}), 503
data = json_object()
result = DatabaseSyncService.create_snapshot(
db_name=site.db_name,
name=data.get('name', f'{app.name}_{site.id}'),
tag=data.get('tag'),
commit_sha=site.last_deploy_commit,
host=site.db_host, user=site.db_user,
password=WordPressEnvService._get_db_password(site),
exclude_tables=data.get('exclude_tables', []),
)
if not result.get('success'):
return jsonify(result), 500
snap_meta = result['snapshot']
snapshot = DatabaseSnapshot(
site_id=site.id, name=snap_meta['name'], tag=data.get('tag'),
file_path=snap_meta['file_path'], size_bytes=snap_meta['size_bytes'],
compressed=snap_meta['compressed'], commit_sha=site.last_deploy_commit,
tables_included=json.dumps(snap_meta.get('tables', [])),
row_count=snap_meta.get('row_count', 0), status='completed',
)
db.session.add(snapshot)
db.session.commit()
DatabaseSyncService.upload_snapshot_offsite(snapshot.file_path)
return jsonify({'success': True, 'snapshot': snapshot.to_dict()}), 201
@apps_bp.route('/<int:app_id>/db-snapshots/<int:snapshot_id>', methods=['DELETE'])
@jwt_required()
def delete_app_db_snapshot(app_id, snapshot_id):
"""Delete a WordPress DB snapshot for an app."""
app = require_app(app_id, write=True)
from app.models.wordpress_site import DatabaseSnapshot
from app.services.db_sync_service import DatabaseSyncService
site = _wp_site_for_app(app)
if not site:
return jsonify({'error': 'This service is not a WordPress site'}), 400
snapshot = DatabaseSnapshot.query.filter_by(id=snapshot_id, site_id=site.id).first()
if not snapshot:
raise not_found('snapshot')
DatabaseSyncService.delete_snapshot(snapshot.file_path)
db.session.delete(snapshot)
db.session.commit()
return jsonify({'success': True, 'message': 'Snapshot deleted'}), 200