mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-09 05:24:49 +09:00
Introduce a deployment jobs system to support cross-server template installations and remote execution. Adds DeploymentJob and DeploymentJobLog models, DeploymentJobService for job lifecycle, and DeploymentPlanRunner to execute plans locally or via agents. TemplateService gains build_install_plan and related helpers to produce executable plans; Template installs can run async (202) or synchronously. Backend API/CLI updated: new deployment-jobs endpoints, apps API uses RemoteDockerService for remote compose actions, and CLI commands (deploy-template, deployment-status, list-servers) were added. Agent and config changes: file access validation with allowed_paths, file write create_dirs option, default allowed paths, and install script/systemd read-write paths updated to permit /var/lib/serverkit and /var/serverkit. Migration file for deployment_jobs schema included.
794 lines
25 KiB
Python
794 lines
25 KiB
Python
import os
|
|
import json
|
|
from flask import Blueprint, request, jsonify
|
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
|
from app import db
|
|
from app.models import Application, User
|
|
from app.services.docker_service import DockerService
|
|
from app.services.remote_docker_service import RemoteDockerService
|
|
from app.services.log_service import LogService
|
|
from app import paths
|
|
|
|
apps_bp = Blueprint('apps', __name__)
|
|
|
|
|
|
def _compose_target(app):
|
|
if app.server_id and app.root_path:
|
|
return os.path.join(app.root_path, 'docker-compose.yml')
|
|
return app.root_path
|
|
|
|
|
|
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
|
|
|
|
|
|
# ==================== 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 user.role != 'admin' and app.user_id != current_user_id:
|
|
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 user.role != 'admin' and target_app.user_id != current_user_id:
|
|
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 user.role != 'admin' and app.user_id != current_user_id:
|
|
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 user.role != 'admin' and app.user_id != current_user_id:
|
|
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 user.role != 'admin' and app.user_id != current_user_id:
|
|
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'
|
|
|
|
if user.role == 'admin':
|
|
query = Application.query
|
|
else:
|
|
query = Application.query.filter_by(user_id=current_user_id)
|
|
|
|
if environment_filter:
|
|
query = query.filter_by(environment_type=environment_filter)
|
|
|
|
apps = query.all()
|
|
|
|
return jsonify({
|
|
'apps': [app.to_dict(include_linked=include_linked) for app in apps]
|
|
}), 200
|
|
|
|
|
|
@apps_bp.route('/<int:app_id>', methods=['GET'])
|
|
@jwt_required()
|
|
def get_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 user.role != 'admin' and app.user_id != current_user_id:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
# Single app requests include linked app info by default
|
|
return jsonify({'app': app.to_dict(include_linked=True)}), 200
|
|
|
|
|
|
@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
|
|
|
|
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'),
|
|
user_id=current_user_id
|
|
)
|
|
|
|
db.session.add(app)
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'message': 'Application created successfully',
|
|
'app': app.to_dict()
|
|
}), 201
|
|
|
|
|
|
@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 user.role != 'admin' and app.user_id != current_user_id:
|
|
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 'root_path' in data:
|
|
app.root_path = data['root_path']
|
|
if 'docker_image' in data:
|
|
app.docker_image = data['docker_image']
|
|
|
|
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 user.role != 'admin' and app.user_id != current_user_id:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
cleanup_results = {
|
|
'docker': None,
|
|
'folder': None,
|
|
'nginx': None
|
|
}
|
|
|
|
# For Docker apps, stop and remove containers/volumes
|
|
if app.app_type == 'docker' and app.root_path:
|
|
try:
|
|
# Stop and remove containers, networks, and volumes
|
|
result = DockerService.compose_down(app.root_path, volumes=True, remove_orphans=True)
|
|
cleanup_results['docker'] = result
|
|
except Exception as e:
|
|
cleanup_results['docker'] = {'error': str(e)}
|
|
|
|
# Delete the app folder
|
|
try:
|
|
if app.root_path and app.root_path.startswith(paths.APPS_DIR):
|
|
if os.path.exists(app.root_path):
|
|
shutil.rmtree(app.root_path)
|
|
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 user.role != 'admin' and app.user_id != current_user_id:
|
|
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_up(
|
|
app.server_id,
|
|
_compose_target(app),
|
|
detach=True,
|
|
user_id=current_user_id
|
|
)
|
|
else:
|
|
result = DockerService.compose_up(app.root_path, detach=True)
|
|
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>/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 user.role != 'admin' and app.user_id != current_user_id:
|
|
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)
|
|
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 user.role != 'admin' and app.user_id != current_user_id:
|
|
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)
|
|
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>/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 user.role != 'admin' and app.user_id != current_user_id:
|
|
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)
|
|
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 user.role != 'admin' and app.user_id != current_user_id:
|
|
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 user.role != 'admin' and app.user_id != current_user_id:
|
|
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 user.role != 'admin' and app.user_id != current_user_id:
|
|
return jsonify({'error': 'Access denied'}), 403
|
|
|
|
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)
|
|
|
|
# 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
|