mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-23 03:27:43 +09:00
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.
2399 lines
90 KiB
Python
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
|