mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-08 04:54:44 +09:00
You could not install a database from ServerKit. The tree's four roots were literals, only MySQL and PostgreSQL reported real state, and no install path existed anywhere in the backend. Rather than add one, this makes a database engine an ordinary app template that declares a top-level `engine:` block — family, wire protocol, admin user, which variable holds the password, what it calls a "table". Presence of that block is the only thing that lists a template in the Databases catalog, and installing it is the template pipeline that already existed: same deployment job, same streaming console. The consequence is the point. Adding CockroachDB is adding a YAML file. So is adding something we have never heard of — author it locally or sync a template repo — and the install form still works, because it is generated from the template's own `variables[]` rather than hand-written per engine. There is no engine model, no engine table, no engine install mechanism to keep in step. `test_dropping_a_new_engine_yaml_lists_it` is the design; if it ever needs code to pass, this went wrong. Twelve engines ship: postgresql, mysql, mariadb, mongodb, redis, valkey, cockroachdb, clickhouse, influxdb, neo4j, meilisearch, couchdb. Each pins its image, binds to loopback, names a data volume, and was checked against the upstream image's actual environment contract — a wrong variable name is a container that boots with authentication silently disabled. Two safety notes worth reading. Engines bind 127.0.0.1 unless explicitly exposed, and the flag is applied after caller variables so a handcrafted payload cannot quietly publish one. And CockroachDB single-node runs `--insecure` because password auth needs a certificate-backed cluster: for engines with no credentials at all the loopback bind is the only control, so public exposure is refused outright rather than warned about. A test pins the passwordless list to exactly cockroachdb, so a future template that drops its password variable fails loudly instead of opening a hole. Also surfaced at last: `ManagedDatabase.owner_application_id` has been accepted by the create endpoints for ages with no UI to set it. Creating a database now offers "Attach to application". Deferred deliberately: new-table builder, SQL dump import, engine extensions (pgvector, TimescaleDB) — each a data-mutation surface deserving its own round. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2540 lines
94 KiB
Python
2540 lines
94 KiB
Python
# Bucket: PER-APP (plan 29 #9). 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
|
|
import shutil
|
|
from datetime import datetime
|
|
from flask import Blueprint, request, jsonify, current_app
|
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
|
from app import db
|
|
from app.models import Application, User
|
|
from app.utils.slug import 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.container_sleep_service import ContainerSleepService
|
|
from app.services.container_scale_service import ContainerScaleService
|
|
from app.services.unit_compose_service import UnitComposeService
|
|
from app.services.app_port_service import AppPortService
|
|
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
|
|
|
|
apps_bp = Blueprint('apps', __name__)
|
|
|
|
|
|
def _compose_target(app):
|
|
"""Return the effective compose file path for remote agent deployments."""
|
|
if app.server_id and app.root_path:
|
|
return os.path.join(app.root_path, app.compose_file or 'docker-compose.yml')
|
|
return os.path.join(app.root_path, app.compose_file or 'docker-compose.yml') if app.root_path else None
|
|
|
|
|
|
def _local_compose_file(app):
|
|
"""Return the compose file to pass to DockerService for local deployments."""
|
|
return app.compose_file if app.compose_file else None
|
|
|
|
|
|
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 _agent_result_failed(result):
|
|
data = result.get('data') if isinstance(result, dict) else None
|
|
return isinstance(data, dict) and data.get('success') is False
|
|
|
|
|
|
def _agent_result_error(result, fallback):
|
|
data = result.get('data') if isinstance(result, dict) else None
|
|
if isinstance(data, dict):
|
|
return data.get('error') or result.get('error') or fallback
|
|
return result.get('error') or fallback
|
|
|
|
|
|
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 _assert_managed_app_path(app_name):
|
|
base_dir = os.path.abspath(paths.APPS_DIR)
|
|
app_path = os.path.abspath(os.path.join(base_dir, app_name))
|
|
if app_path != base_dir and app_path.startswith(base_dir + os.sep):
|
|
return app_path
|
|
raise ValueError('Invalid application path')
|
|
|
|
|
|
def _ensure_local_image_compose(app):
|
|
"""Materialize a compose project for a local docker app that carries a
|
|
``docker_image`` but has no source on disk yet.
|
|
|
|
A BYO-image app (e.g. an ``image:`` manifest service with no repository)
|
|
is created with ``docker_image`` set but ``root_path``/``compose_file``
|
|
NULL, so it has nothing to launch. Render a one-service compose from the
|
|
image and its typed ports, write it under APPS_DIR, and persist the paths;
|
|
the normal ``compose_up`` overlay then injects the app's effective env
|
|
(including resolved vault secrets), so nothing sensitive is written here.
|
|
|
|
No-op when a source already exists, the app has no image, or it targets a
|
|
remote server (the file must live where the deploy actually runs).
|
|
"""
|
|
if app.root_path or not app.docker_image or app.server_id:
|
|
return
|
|
app_path = _assert_managed_app_path(app.name)
|
|
os.makedirs(app_path, exist_ok=True)
|
|
|
|
container = {'name': 'app', 'image': app.docker_image}
|
|
ports = AppPortService.get_ports(app)
|
|
if not ports and app.port:
|
|
# Bind the legacy scalar port to loopback — nginx fronts it; publishing
|
|
# 0.0.0.0 (AppPortService._clean's default) would expose it past nginx.
|
|
ports = [{'host_port': app.port, 'container_port': app.port, 'expose': 'local'}]
|
|
if ports:
|
|
container['ports'] = ports
|
|
if app.healthcheck_path:
|
|
container['health_check'] = {'http_path': app.healthcheck_path}
|
|
|
|
compose_yaml = UnitComposeService.render_yaml(app.name, [container])
|
|
with open(os.path.join(app_path, 'docker-compose.yml'), 'w') as f:
|
|
f.write(compose_yaml)
|
|
|
|
app.root_path = app_path
|
|
app.compose_file = 'docker-compose.yml'
|
|
if not app.managed_by:
|
|
app.managed_by = 'docker_compose'
|
|
db.session.commit()
|
|
|
|
|
|
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.get(app_id)
|
|
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
|
|
if not _can_edit_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
data = request.get_json()
|
|
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:
|
|
return jsonify({'error': 'target_app_id is required'}), 400
|
|
|
|
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.get(target_app_id)
|
|
if not target_app:
|
|
return jsonify({'error': 'Target application not found'}), 404
|
|
|
|
if not _can_edit_app(user, target_app):
|
|
return jsonify({'error': 'Access denied to target application'}), 403
|
|
|
|
if app.app_type != target_app.app_type:
|
|
return jsonify({'error': 'Apps must be of the same type to link'}), 400
|
|
|
|
if app_id == target_app_id:
|
|
return jsonify({'error': 'Cannot link an app 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': 'Apps linked successfully',
|
|
'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.get(app_id)
|
|
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
|
|
if not _can_access_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
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.get(app_id)
|
|
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
|
|
if not _can_edit_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
if not app.linked_app_id:
|
|
return jsonify({'error': 'App is not linked to any other app'}), 400
|
|
|
|
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': 'Apps unlinked successfully',
|
|
'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.get(app_id)
|
|
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
|
|
if not _can_edit_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
data = request.get_json()
|
|
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 = WorkspaceService.scope_query(Application.query, 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)
|
|
|
|
apps = query.all()
|
|
deploy_configs = GitService.get_config().get('apps', {})
|
|
|
|
return jsonify({
|
|
'apps': [
|
|
_attach_deploy_config(app.to_dict(include_linked=include_linked), deploy_configs)
|
|
for app in apps
|
|
]
|
|
}), 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 = User.query.get(get_jwt_identity())
|
|
app = Application.query.get(app_id)
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
# Use user.id (int) for comparisons — get_jwt_identity() is the stringified token id.
|
|
if user.role != 'admin' and app.user_id != user.id:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
target = (request.get_json() or {}).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 user.role != '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 = User.query.get(get_jwt_identity())
|
|
data = request.get_json() or {}
|
|
|
|
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.get(app_id)
|
|
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)} application(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 = User.query.get(get_jwt_identity())
|
|
app = Application.query.get(app_id)
|
|
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
|
|
if not _can_access_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
# 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 = User.query.get(get_jwt_identity())
|
|
app = Application.query.get(app_id)
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
if not _can_access_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
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 = User.query.get(get_jwt_identity())
|
|
app = Application.query.get(app_id)
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
if user.role != 'admin' and app.user_id != user.id:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
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 = User.query.get(get_jwt_identity())
|
|
app = Application.query.get(app_id)
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
if user.role != 'admin' and app.user_id != user.id:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
data = request.get_json() or {}
|
|
grantee_id = data.get('user_id')
|
|
if not grantee_id:
|
|
return jsonify({'error': 'user_id is required'}), 400
|
|
grantee = User.query.get(grantee_id)
|
|
if not grantee:
|
|
return jsonify({'error': 'User not found'}), 404
|
|
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 = User.query.get(get_jwt_identity())
|
|
app = Application.query.get(app_id)
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
if user.role != 'admin' and app.user_id != user.id:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
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."""
|
|
current_user_id = get_jwt_identity()
|
|
user = User.query.get(current_user_id)
|
|
if not user or user.role != 'admin':
|
|
return jsonify({'error': 'Admin access required'}), 403
|
|
|
|
data = request.get_json() or {}
|
|
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=current_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:
|
|
return jsonify({'error': 'repo_url is required'}), 400
|
|
|
|
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
|
|
|
|
if Application.query.filter_by(name=name, server_id=None).first():
|
|
return jsonify({'error': f'An application named "{name}" already exists'}), 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'App directory already exists: {app_path}'}), 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)
|
|
|
|
app = Application(
|
|
name=name,
|
|
app_type=resolved_app_type,
|
|
status='stopped',
|
|
root_path=app_path,
|
|
user_id=current_user_id,
|
|
port=port,
|
|
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),
|
|
project_id=project_id,
|
|
environment_id=environment_id,
|
|
)
|
|
|
|
try:
|
|
db.session.add(app)
|
|
db.session.commit()
|
|
|
|
deploy_result = GitService.configure_deployment(
|
|
app_id=app.id,
|
|
app_path=app.root_path,
|
|
repo_url=deploy_repo_url,
|
|
branch=branch or 'main',
|
|
auto_deploy=auto_deploy,
|
|
)
|
|
if not deploy_result.get('success'):
|
|
raise RuntimeError(deploy_result.get('error', 'Failed to configure deployment'))
|
|
|
|
build_result = BuildService.configure_build(
|
|
app_id=app.id,
|
|
app_path=app.root_path,
|
|
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,
|
|
)
|
|
if not build_result.get('success'):
|
|
raise RuntimeError(build_result.get('error', 'Failed to configure build'))
|
|
|
|
# Stop dropping what we detect: persist the manifest, seed non-secret
|
|
# env values, and record the health-check path (plan 17, Phase 1).
|
|
manifest_summary = None
|
|
try:
|
|
from app.services.manifest_persistence_service import ManifestPersistenceService
|
|
manifest_summary = ManifestPersistenceService.apply_import(
|
|
app, manifest, user_id=current_user_id,
|
|
source_repo=deploy_repo_url, source_ref=branch or 'main',
|
|
)
|
|
except Exception:
|
|
manifest_summary = None
|
|
|
|
# Actually deploy + start the service: hand it to the same observable
|
|
# DeploymentJob pipeline template installs use (kind 'app_deploy' →
|
|
# unified job 'deploy.app' → DeploymentService.deploy). A queue failure
|
|
# must NOT fail app creation — the service stays 'stopped' and the user
|
|
# can deploy manually from the Builds tab.
|
|
deploy_job_id = None
|
|
try:
|
|
from app.services.deployment_job_service import DeploymentJobService
|
|
enqueue_result = DeploymentJobService.enqueue_app_deploy(
|
|
app, user_id=current_user_id, trigger='install')
|
|
if enqueue_result.get('success'):
|
|
deploy_job_id = enqueue_result.get('job_id')
|
|
else:
|
|
current_app.logger.warning(
|
|
'app deploy enqueue failed for app %s: %s',
|
|
app.id, enqueue_result.get('error'))
|
|
except Exception as exc:
|
|
current_app.logger.warning(
|
|
'app deploy enqueue failed for app %s: %s', app.id, exc)
|
|
|
|
return jsonify({
|
|
'message': 'Repository service created',
|
|
'app': _attach_deploy_config(app.to_dict(include_linked=True)),
|
|
'deploy_job_id': deploy_job_id,
|
|
'manifest_import': manifest_summary,
|
|
'deploy_config': {
|
|
'repo_url': _safe_repo_url(deploy_repo_url),
|
|
'branch': branch or 'main',
|
|
'auto_deploy': auto_deploy,
|
|
'webhook_url': deploy_result.get('webhook_url'),
|
|
},
|
|
'build_config': build_result.get('config'),
|
|
'detection': detection,
|
|
'manifest': manifest,
|
|
}), 201
|
|
except Exception as exc:
|
|
db.session.rollback()
|
|
if app.id:
|
|
GitService.remove_deployment(app.id)
|
|
existing_app = Application.query.get(app.id)
|
|
if existing_app:
|
|
db.session.delete(existing_app)
|
|
db.session.commit()
|
|
if os.path.abspath(app_path).startswith(os.path.abspath(paths.APPS_DIR) + os.sep):
|
|
shutil.rmtree(app_path, ignore_errors=True)
|
|
return jsonify({'error': str(exc)}), 400
|
|
|
|
|
|
@apps_bp.route('', methods=['POST'])
|
|
@jwt_required()
|
|
def create_app():
|
|
current_user_id = get_jwt_identity()
|
|
data = request.get_json()
|
|
|
|
if not data:
|
|
return jsonify({'error': 'No data provided'}), 400
|
|
|
|
name = 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
|
|
|
|
# Stamp the workspace (#33): the requested one (membership-checked) or the default.
|
|
from app.services.workspace_service import WorkspaceService
|
|
user = User.query.get(current_user_id)
|
|
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': 'Application created successfully',
|
|
'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)."""
|
|
current_user_id = get_jwt_identity()
|
|
user = User.query.get(current_user_id)
|
|
if not user or user.role != 'admin':
|
|
return jsonify({'error': 'Admin access required'}), 403
|
|
|
|
data = request.get_json() or {}
|
|
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:
|
|
return jsonify({'error': 'root_path is required'}), 400
|
|
|
|
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'
|
|
|
|
if Application.query.filter_by(name=name).first():
|
|
return jsonify({'error': f'An application named "{name}" already exists'}), 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=current_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."""
|
|
current_user_id = get_jwt_identity()
|
|
user = User.query.get(current_user_id)
|
|
if not user or user.role != 'admin':
|
|
return jsonify({'error': 'Admin access required'}), 403
|
|
|
|
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
|
|
|
|
existing = Application.query.filter_by(name=name).first()
|
|
is_update = existing is not None 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': f'An application named "{name}" already exists'}), 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=current_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
|
|
return jsonify({'error': str(exc)}), 400
|
|
|
|
|
|
@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.get(app_id)
|
|
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
|
|
if not _can_access_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
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 Exception as e:
|
|
return jsonify({'error': str(e)}), 400
|
|
|
|
|
|
@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.get(app_id)
|
|
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
|
|
if not _can_edit_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
if app.source != 'upload':
|
|
return jsonify({'error': 'Rollback is only supported for upload-based apps'}), 400
|
|
|
|
data = request.get_json() or {}
|
|
target = data.get('version')
|
|
if target is None:
|
|
return jsonify({'error': 'version is required'}), 400
|
|
|
|
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 Exception as e:
|
|
return jsonify({'error': str(e)}), 400
|
|
|
|
|
|
@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.get(app_id)
|
|
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
|
|
if not _can_edit_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
data = request.get_json()
|
|
|
|
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 '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': 'Application updated successfully',
|
|
'app': app.to_dict()
|
|
}), 200
|
|
|
|
|
|
@apps_bp.route('/<int:app_id>', methods=['DELETE'])
|
|
@jwt_required()
|
|
def delete_app(app_id):
|
|
import shutil
|
|
from app.services.nginx_service import NginxService
|
|
|
|
current_user_id = get_jwt_identity()
|
|
user = User.query.get(current_user_id)
|
|
app = Application.query.get(app_id)
|
|
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
|
|
if not user.is_admin and app.user_id != user.id:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
cleanup_results = {
|
|
'docker': None,
|
|
'folder': None,
|
|
'nginx': None
|
|
}
|
|
|
|
# Appliance tier (plan 35): close any public firewall ports this app opened
|
|
# so a delete leaves no orphaned holes in the firewall.
|
|
try:
|
|
from app.services.app_port_service import AppPortService
|
|
declared_ports = AppPortService.get_ports(app)
|
|
if declared_ports:
|
|
cleanup_results['firewall'] = AppPortService.close_firewall(declared_ports)
|
|
except Exception as e:
|
|
cleanup_results['firewall'] = {'error': str(e)}
|
|
|
|
# For Docker apps, stop and remove containers (and, by default, volumes).
|
|
#
|
|
# Database engines are the exception: destroying the data volume is the one
|
|
# irreversible part of an uninstall, so an app installed from a template
|
|
# carrying an `engine:` block KEEPS its volumes unless the caller explicitly
|
|
# asks for `?remove_data=true`. Everything else keeps the historic
|
|
# volumes=True default. `?remove_data=` (or `?volumes=`) overrides either way.
|
|
remove_data = _remove_data_flag(app)
|
|
if app.app_type == 'docker' and app.root_path:
|
|
try:
|
|
# Stop and remove containers, networks, and (optionally) volumes
|
|
result = DockerService.compose_down(
|
|
app.root_path,
|
|
volumes=remove_data,
|
|
remove_orphans=True,
|
|
compose_file=_local_compose_file(app)
|
|
)
|
|
cleanup_results['docker'] = result
|
|
cleanup_results['data_volumes_removed'] = remove_data
|
|
except Exception as e:
|
|
cleanup_results['docker'] = {'error': str(e)}
|
|
|
|
# Delete the app folder only for ServerKit-managed uploads.
|
|
# Manual apps point at existing paths that must not be removed.
|
|
try:
|
|
if app.source == 'upload' and app.root_path and app.root_path.startswith(paths.APPS_DIR):
|
|
app_storage = get_app_storage_dir(app.name)
|
|
if os.path.exists(app_storage):
|
|
shutil.rmtree(app_storage)
|
|
cleanup_results['folder'] = {'success': True}
|
|
except Exception as e:
|
|
cleanup_results['folder'] = {'error': str(e)}
|
|
|
|
# Remove nginx site config
|
|
try:
|
|
NginxService.disable_site(app.name)
|
|
NginxService.delete_site(app.name)
|
|
cleanup_results['nginx'] = {'success': True}
|
|
except Exception as e:
|
|
cleanup_results['nginx'] = {'error': str(e)}
|
|
|
|
# Delete from database
|
|
db.session.delete(app)
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'message': 'Application deleted successfully',
|
|
'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.get(app_id)
|
|
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
|
|
if not _can_edit_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
# A local BYO-image docker app (image set, no source yet) has no compose to
|
|
# launch — materialize one on first start so it can actually run.
|
|
if app.app_type == 'docker' and not app.root_path and app.docker_image and not app.server_id:
|
|
try:
|
|
_ensure_local_image_compose(app)
|
|
except ValueError as e:
|
|
return jsonify({'error': str(e)}), 400
|
|
|
|
# Handle Docker apps
|
|
if app.app_type == 'docker' and app.root_path:
|
|
if app.server_id:
|
|
result = RemoteDockerService.compose_up(
|
|
app.server_id,
|
|
_compose_target(app),
|
|
detach=True,
|
|
user_id=current_user_id
|
|
)
|
|
else:
|
|
# Authenticate a bound private registry before compose pulls the
|
|
# image; best-effort, always logs back out. No-op without registry_id.
|
|
_registry = ContainerRegistryService.login_for_app(app)
|
|
try:
|
|
result = DockerService.compose_up(
|
|
app.root_path,
|
|
detach=True,
|
|
compose_file=_local_compose_file(app)
|
|
)
|
|
finally:
|
|
ContainerRegistryService.logout_for_app(_registry)
|
|
if not result.get('success') or _agent_result_failed(result):
|
|
return jsonify({'error': _agent_result_error(result, 'Failed to start containers')}), 400
|
|
|
|
app.status = 'running'
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'message': 'Application 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.get(app_id)
|
|
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
if not _can_edit_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
# 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 supported for docker-compose apps. '
|
|
'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()
|
|
|
|
# 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 = User.query.get(get_jwt_identity())
|
|
app = Application.query.get(app_id)
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
if not _can_access_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
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 = User.query.get(get_jwt_identity())
|
|
app = Application.query.get(app_id)
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
if not _can_edit_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
data = request.get_json() or {}
|
|
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 = User.query.get(get_jwt_identity())
|
|
app = Application.query.get(app_id)
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
if not _can_edit_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
result = ContainerSleepService.sleep_app(app_id)
|
|
if not result.get('success'):
|
|
return jsonify({'error': result['error']}), 400
|
|
return jsonify({'message': 'Application 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 = User.query.get(get_jwt_identity())
|
|
app = Application.query.get(app_id)
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
if not _can_edit_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
result = ContainerSleepService.wake_app(app_id)
|
|
if not result.get('success'):
|
|
return jsonify({'error': result['error']}), 400
|
|
return jsonify({'message': 'Application 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 = User.query.get(get_jwt_identity())
|
|
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 = User.query.get(get_jwt_identity())
|
|
app = Application.query.get(app_id)
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
if not _can_access_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
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 = User.query.get(get_jwt_identity())
|
|
app = Application.query.get(app_id)
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
if not _can_edit_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
data = request.get_json() or {}
|
|
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 = User.query.get(get_jwt_identity())
|
|
app = Application.query.get(app_id)
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
if not _can_edit_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
data = request.get_json() or {}
|
|
if 'enabled' not in data:
|
|
return jsonify({'error': "'enabled' is required"}), 400
|
|
|
|
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),
|
|
'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):
|
|
"""Manually clear the micro-cache.
|
|
|
|
The cache is ONE shared nginx zone for all opted-in sites (zones must be
|
|
declared statically, so per-site zones don't scale) — purging therefore
|
|
wipes cached entries for every opted-in site. With the 10-second TTL this
|
|
is near-harmless, and no nginx reload is needed.
|
|
"""
|
|
user = User.query.get(get_jwt_identity())
|
|
app = Application.query.get(app_id)
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
if not _can_edit_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
from app.services.nginx_service import NginxService
|
|
result = NginxService.purge_micro_cache()
|
|
if not result.get('success'):
|
|
return jsonify({'error': result.get('error', 'Purge failed')}), 500
|
|
|
|
response = {'message': result.get('message', 'Micro-cache cleared')}
|
|
if result.get('note'):
|
|
response['note'] = result['note']
|
|
return jsonify(response), 200
|
|
|
|
|
|
@apps_bp.route('/<int:app_id>/scale-policy', methods=['GET'])
|
|
@jwt_required()
|
|
def get_scale_policy(app_id):
|
|
user = User.query.get(get_jwt_identity())
|
|
app = Application.query.get(app_id)
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
if not _can_access_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
return jsonify(ContainerScaleService.get_or_create_policy(app_id).to_dict())
|
|
|
|
|
|
@apps_bp.route('/<int:app_id>/scale-policy', methods=['PUT'])
|
|
@jwt_required()
|
|
def update_scale_policy(app_id):
|
|
user = User.query.get(get_jwt_identity())
|
|
app = Application.query.get(app_id)
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
if not _can_edit_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
data = request.get_json() or {}
|
|
policy = ContainerScaleService.set_policy(app_id, **data)
|
|
return jsonify(policy.to_dict())
|
|
|
|
|
|
@apps_bp.route('/<int:app_id>/scale', methods=['POST'])
|
|
@jwt_required()
|
|
def scale_app(app_id):
|
|
user = User.query.get(get_jwt_identity())
|
|
app = Application.query.get(app_id)
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
if not _can_edit_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
data = request.get_json() or {}
|
|
if data.get('replicas') is None:
|
|
return jsonify({'error': 'replicas is required'}), 400
|
|
result = ContainerScaleService.scale_to(app_id, data['replicas'])
|
|
if not result.get('success'):
|
|
return jsonify({'error': result['error']}), 400
|
|
return jsonify(result)
|
|
|
|
|
|
@apps_bp.route('/<int:app_id>/scale/evaluate', methods=['POST'])
|
|
@jwt_required()
|
|
def evaluate_scale(app_id):
|
|
user = User.query.get(get_jwt_identity())
|
|
app = Application.query.get(app_id)
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
if not _can_edit_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
result = ContainerScaleService.evaluate(app_id)
|
|
if not result.get('success'):
|
|
return jsonify({'error': result.get('error', 'Evaluation failed')}), 400
|
|
return jsonify(result)
|
|
|
|
|
|
@apps_bp.route('/scale-sweep', methods=['POST'])
|
|
@jwt_required()
|
|
def scale_sweep():
|
|
"""Evaluate every enabled auto-scaling policy. Intended for cron/scheduler."""
|
|
user = User.query.get(get_jwt_identity())
|
|
if not (user and user.is_admin):
|
|
return jsonify({'error': 'Admin access required'}), 403
|
|
return jsonify(ContainerScaleService.sweep())
|
|
|
|
|
|
@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.get(app_id)
|
|
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
|
|
if not _can_edit_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
# Handle Docker apps
|
|
if app.app_type == 'docker' and app.root_path:
|
|
if app.server_id:
|
|
result = RemoteDockerService.compose_down(
|
|
app.server_id,
|
|
_compose_target(app),
|
|
user_id=current_user_id
|
|
)
|
|
else:
|
|
result = DockerService.compose_down(
|
|
app.root_path,
|
|
compose_file=_local_compose_file(app)
|
|
)
|
|
if not result.get('success') or _agent_result_failed(result):
|
|
return jsonify({'error': _agent_result_error(result, 'Failed to stop containers')}), 400
|
|
|
|
app.status = 'stopped'
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'message': 'Application 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.get(app_id)
|
|
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
|
|
if not _can_edit_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
# Handle Docker apps
|
|
if app.app_type == 'docker' and app.root_path:
|
|
if app.server_id:
|
|
result = RemoteDockerService.compose_restart(
|
|
app.server_id,
|
|
_compose_target(app),
|
|
user_id=current_user_id
|
|
)
|
|
else:
|
|
result = DockerService.compose_restart(
|
|
app.root_path,
|
|
compose_file=_local_compose_file(app)
|
|
)
|
|
if not result.get('success') or _agent_result_failed(result):
|
|
return jsonify({'error': _agent_result_error(result, 'Failed to restart containers')}), 400
|
|
|
|
app.status = 'running'
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'message': 'Application 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 = User.query.get(get_jwt_identity())
|
|
app = Application.query.get(app_id)
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
if not _can_access_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
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.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.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.get(app_id)
|
|
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
|
|
if not _can_access_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
lines = request.args.get('lines', 100, type=int)
|
|
log_type = request.args.get('type', 'all')
|
|
|
|
# For Docker apps, get docker compose logs
|
|
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.get(app_id)
|
|
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
|
|
if not _can_access_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
# 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 application',
|
|
'hint': 'The application may not have been started yet'
|
|
}), 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.get(app_id)
|
|
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
|
|
if not _can_access_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
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.get(app_id)
|
|
|
|
if not app:
|
|
return jsonify({'error': 'Application not found'}), 404
|
|
|
|
if not _can_access_app(user, app):
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
# 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) ====================
|
|
|
|
def _load_app_for_backup(app_id, edit=False):
|
|
"""Load an app the current user may access (or edit). Returns (app, error)."""
|
|
user = User.query.get(get_jwt_identity())
|
|
app = Application.query.get(app_id)
|
|
if not app:
|
|
return None, (jsonify({'error': 'Application not found'}), 404)
|
|
allowed = _can_edit_app(user, app) if edit else _can_access_app(user, app)
|
|
if not allowed:
|
|
return None, (jsonify({'error': 'Access denied'}), 403)
|
|
return app, None
|
|
|
|
|
|
@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, err = _load_app_for_backup(app_id)
|
|
if err:
|
|
return err
|
|
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, err = _load_app_for_backup(app_id, edit=True)
|
|
if err:
|
|
return err
|
|
policy = BackupPolicyService.get_or_create_policy('application', app_id)
|
|
try:
|
|
BackupPolicyService.update_policy(policy, request.get_json() or {})
|
|
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, err = _load_app_for_backup(app_id, edit=True)
|
|
if err:
|
|
return err
|
|
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, err = _load_app_for_backup(app_id)
|
|
if err:
|
|
return err
|
|
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, err = _load_app_for_backup(app_id, edit=True)
|
|
if err:
|
|
return err
|
|
policy = BackupPolicyService.get_or_create_policy('application', app_id)
|
|
try:
|
|
job = BackupPolicyService.request_restore(policy, run_id, request.get_json() or {})
|
|
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, err = _load_app_for_backup(app_id, edit=True)
|
|
if err:
|
|
return err
|
|
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, err = _load_app_for_backup(app_id, edit=True)
|
|
if err:
|
|
return err
|
|
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 (§7 unification).
|
|
# 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, err = _load_app_for_backup(app_id)
|
|
if err:
|
|
return err
|
|
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, err = _load_app_for_backup(app_id)
|
|
if err:
|
|
return err
|
|
from app.models.wordpress_site import DatabaseSnapshot
|
|
site = _wp_site_for_app(app)
|
|
if not site:
|
|
return jsonify({'error': 'This application 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, err = _load_app_for_backup(app_id, edit=True)
|
|
if err:
|
|
return err
|
|
from app.models.wordpress_site import DatabaseSnapshot
|
|
from app.services.db_sync_service import DatabaseSyncService
|
|
from app.services.wordpress_bridge import wordpress_env_service
|
|
WordPressEnvService = wordpress_env_service()
|
|
site = _wp_site_for_app(app)
|
|
if not site:
|
|
return jsonify({'error': 'This application is not a WordPress site'}), 400
|
|
data = request.get_json() or {}
|
|
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, err = _load_app_for_backup(app_id, edit=True)
|
|
if err:
|
|
return err
|
|
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 application is not a WordPress site'}), 400
|
|
snapshot = DatabaseSnapshot.query.filter_by(id=snapshot_id, site_id=site.id).first()
|
|
if not snapshot:
|
|
return jsonify({'error': 'Snapshot not found'}), 404
|
|
DatabaseSyncService.delete_snapshot(snapshot.file_path)
|
|
db.session.delete(snapshot)
|
|
db.session.commit()
|
|
return jsonify({'success': True, 'message': 'Snapshot deleted'}), 200
|