from flask import jsonify, request, current_app, Blueprint from models import ( KeyValueSettings, Instance ) from extensions import db, csrf from routes.admin_api import token_required import json import smtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from email.utils import formatdate from datetime import datetime import requests import base64 from flask_wtf.csrf import CSRFProtect from functools import wraps import os launch_api = Blueprint('launch_api', __name__) # Connection Settings @launch_api.route('/test-portainer-connection', methods=['POST']) @csrf.exempt def test_portainer_connection(): data = request.get_json() url = data.get('url') api_key = data.get('api_key') if not url or not api_key: return jsonify({'error': 'Missing required fields'}), 400 try: # Test Portainer connection response = requests.get( f"{url.rstrip('/')}/api/status", headers={ 'X-API-Key': api_key, 'Accept': 'application/json' }, timeout=5 ) if response.status_code == 200: return jsonify({'message': 'Connection successful'}) else: return jsonify({'error': 'Failed to connect to Portainer'}), 400 except Exception as e: return jsonify({'error': str(e)}), 500 @launch_api.route('/test-nginx-connection', methods=['POST']) @csrf.exempt def test_nginx_connection(): data = request.get_json() url = data.get('url') username = data.get('username') password = data.get('password') if not url or not username or not password: return jsonify({'error': 'Missing required fields'}), 400 try: # First, get the JWT token token_response = requests.post( f"{url.rstrip('/')}/api/tokens", json={ 'identity': username, 'secret': password }, headers={'Content-Type': 'application/json'}, timeout=5 ) if token_response.status_code != 200: return jsonify({'error': 'Failed to authenticate with NGINX Proxy Manager'}), 400 token_data = token_response.json() token = token_data.get('token') if not token: return jsonify({'error': 'No token received from NGINX Proxy Manager'}), 400 # Now test the connection using the token response = requests.get( f"{url.rstrip('/')}/api/nginx/proxy-hosts", headers={ 'Authorization': f'Bearer {token}', 'Accept': 'application/json' }, timeout=5 ) if response.status_code == 200: return jsonify({'message': 'Connection successful'}) else: return jsonify({'error': 'Failed to connect to NGINX Proxy Manager'}), 400 except Exception as e: return jsonify({'error': str(e)}), 500 @launch_api.route('/save-portainer-connection', methods=['POST']) @csrf.exempt @token_required def save_portainer_connection(current_user): if not current_user.is_admin: return jsonify({'error': 'Unauthorized'}), 403 data = request.get_json() url = data.get('url') api_key = data.get('api_key') if not url or not api_key: return jsonify({'error': 'Missing required fields'}), 400 try: # Save Portainer settings KeyValueSettings.set_value('portainer_settings', { 'url': url, 'api_key': api_key }) return jsonify({'message': 'Settings saved successfully'}) except Exception as e: return jsonify({'error': str(e)}), 500 @launch_api.route('/save-nginx-connection', methods=['POST']) @csrf.exempt @token_required def save_nginx_connection(current_user): if not current_user.is_admin: return jsonify({'error': 'Unauthorized'}), 403 data = request.get_json() url = data.get('url') username = data.get('username') password = data.get('password') if not url or not username or not password: return jsonify({'error': 'Missing required fields'}), 400 try: # Save NGINX Proxy Manager settings KeyValueSettings.set_value('nginx_settings', { 'url': url, 'username': username, 'password': password }) return jsonify({'message': 'Settings saved successfully'}) except Exception as e: return jsonify({'error': str(e)}), 500 @launch_api.route('/generate-gitea-token', methods=['POST']) @csrf.exempt def generate_gitea_token(): """Generate a new Gitea API token""" data = request.get_json() if not data or 'url' not in data or 'username' not in data or 'password' not in data: return jsonify({'message': 'Missing required fields'}), 400 try: headers = { 'Content-Type': 'application/json' } if data.get('otp'): headers['X-Gitea-OTP'] = data['otp'] # Generate token with required scopes token_data = { 'name': f'docupulse_{datetime.utcnow().strftime("%Y%m%d_%H%M%S")}', 'scopes': [ 'read:activitypub', 'read:issue', 'write:misc', 'read:notification', 'read:organization', 'read:package', 'read:repository', 'read:user' ] } # Make request to Gitea API response = requests.post( f'{data["url"]}/api/v1/users/{data["username"]}/tokens', headers=headers, json=token_data, auth=(data['username'], data['password']) ) if response.status_code == 201: token_data = response.json() return jsonify({ 'token': token_data['sha1'], 'name': token_data['name'], 'token_last_eight': token_data['token_last_eight'] }), 200 else: return jsonify({'message': f'Failed to generate token: {response.json().get("message", "Unknown error")}'}), 400 except Exception as e: return jsonify({'message': f'Failed to generate token: {str(e)}'}), 400 @launch_api.route('/list-gitea-repos', methods=['POST']) @csrf.exempt def list_gitea_repos(): """List repositories from Gitea""" data = request.get_json() if not data or 'url' not in data or 'token' not in data: return jsonify({'message': 'Missing required fields'}), 400 try: headers = { 'Accept': 'application/json' } # First try token in Authorization header headers['Authorization'] = f'token {data["token"]}' # Get user's repositories response = requests.get( f'{data["url"]}/api/v1/user/repos', headers=headers ) # If that fails, try token as query parameter if response.status_code != 200: response = requests.get( f'{data["url"]}/api/v1/user/repos?token={data["token"]}', headers={'Accept': 'application/json'} ) if response.status_code == 200: return jsonify({ 'repositories': response.json() }), 200 else: return jsonify({'message': f'Failed to list repositories: {response.json().get("message", "Unknown error")}'}), 400 except Exception as e: return jsonify({'message': f'Failed to list repositories: {str(e)}'}), 400 @launch_api.route('/list-gitea-branches', methods=['POST']) @csrf.exempt def list_gitea_branches(): """List branches from a Gitea repository""" data = request.get_json() if not data or 'url' not in data or 'token' not in data or 'repo' not in data: return jsonify({'message': 'Missing required fields'}), 400 try: # Try different authentication methods headers = { 'Accept': 'application/json' } # First try token in Authorization header headers['Authorization'] = f'token {data["token"]}' # Get repository branches response = requests.get( f'{data["url"]}/api/v1/repos/{data["repo"]}/branches', headers=headers ) # If that fails, try token as query parameter if response.status_code != 200: response = requests.get( f'{data["url"]}/api/v1/repos/{data["repo"]}/branches?token={data["token"]}', headers={'Accept': 'application/json'} ) if response.status_code == 200: return jsonify({ 'branches': response.json() }), 200 else: return jsonify({'message': f'Failed to list branches: {response.json().get("message", "Unknown error")}'}), 400 except Exception as e: return jsonify({'message': f'Failed to list branches: {str(e)}'}), 400 @launch_api.route('/list-gitlab-repos', methods=['POST']) @csrf.exempt def list_gitlab_repos(): """List repositories from GitLab""" data = request.get_json() if not data or 'url' not in data or 'token' not in data: return jsonify({'message': 'Missing required fields'}), 400 try: headers = { 'PRIVATE-TOKEN': data['token'], 'Accept': 'application/json' } # Get user's projects (repositories) response = requests.get( f'{data["url"]}/api/v4/projects', headers=headers, params={'membership': 'true'} # Only get projects where user is a member ) if response.status_code == 200: return jsonify({ 'repositories': response.json() }), 200 else: return jsonify({'message': f'Failed to list repositories: {response.json().get("message", "Unknown error")}'}), 400 except Exception as e: return jsonify({'message': f'Failed to list repositories: {str(e)}'}), 400 @launch_api.route('/test-git-connection', methods=['POST']) @csrf.exempt def test_git_connection(): """Test the connection to a Git repository""" data = request.get_json() if not data or 'provider' not in data or 'url' not in data or 'username' not in data or 'token' not in data or 'repo' not in data: return jsonify({'message': 'Missing required fields'}), 400 try: if data['provider'] == 'gitea': # Test Gitea connection with different authentication methods headers = { 'Accept': 'application/json' } # First try token in Authorization header headers['Authorization'] = f'token {data["token"]}' # Try to get repository information response = requests.get( f'{data["url"]}/api/v1/repos/{data["repo"]}', headers=headers ) # If that fails, try token as query parameter if response.status_code != 200: response = requests.get( f'{data["url"]}/api/v1/repos/{data["repo"]}?token={data["token"]}', headers={'Accept': 'application/json'} ) if response.status_code == 200: return jsonify({'message': 'Connection successful'}), 200 else: return jsonify({'message': f'Connection failed: {response.json().get("message", "Unknown error")}'}), 400 elif data['provider'] == 'gitlab': # Test GitLab connection headers = { 'PRIVATE-TOKEN': data['token'], 'Accept': 'application/json' } # Try to get repository information response = requests.get( f'{data["url"]}/api/v4/projects/{data["repo"].replace("/", "%2F")}', headers=headers ) if response.status_code == 200: return jsonify({'message': 'Connection successful'}), 200 else: return jsonify({'message': f'Connection failed: {response.json().get("message", "Unknown error")}'}), 400 else: return jsonify({'message': 'Invalid Git provider'}), 400 except Exception as e: return jsonify({'message': f'Connection failed: {str(e)}'}), 400 @launch_api.route('/save-git-connection', methods=['POST']) @csrf.exempt @token_required def save_git_connection(current_user): if not current_user.is_admin: return jsonify({'error': 'Unauthorized'}), 403 data = request.get_json() provider = data.get('provider') url = data.get('url') username = data.get('username') token = data.get('token') repo = data.get('repo') if not provider or not url or not username or not token or not repo: return jsonify({'error': 'Missing required fields'}), 400 if provider not in ['gitea', 'gitlab']: return jsonify({'error': 'Invalid provider'}), 400 try: # Save Git settings KeyValueSettings.set_value('git_settings', { 'provider': provider, 'url': url, 'username': username, 'token': token, 'repo': repo }) return jsonify({'message': 'Settings saved successfully'}) except Exception as e: return jsonify({'error': str(e)}), 500 @launch_api.route('/create-proxy-host', methods=['POST']) @csrf.exempt @token_required def create_proxy_host(current_user): if not current_user.is_admin: return jsonify({'error': 'Unauthorized'}), 403 data = request.get_json() domains = data.get('domains') scheme = data.get('scheme', 'http') forward_ip = data.get('forward_ip') forward_port = data.get('forward_port') if not domains or not forward_ip or not forward_port: return jsonify({'error': 'Missing required fields'}), 400 try: # Get NGINX settings nginx_settings = KeyValueSettings.get_value('nginx_settings') if not nginx_settings: return jsonify({'error': 'NGINX settings not configured'}), 400 # First, get the JWT token token_response = requests.post( f"{nginx_settings['url'].rstrip('/')}/api/tokens", json={ 'identity': nginx_settings['username'], 'secret': nginx_settings['password'] }, headers={'Content-Type': 'application/json'}, timeout=5 ) if token_response.status_code != 200: return jsonify({'error': 'Failed to authenticate with NGINX Proxy Manager'}), 400 token_data = token_response.json() token = token_data.get('token') if not token: return jsonify({'error': 'No token received from NGINX Proxy Manager'}), 400 # Create the proxy host proxy_host_data = { 'domain_names': domains, 'forward_scheme': scheme, 'forward_host': forward_ip, 'forward_port': int(forward_port), 'ssl_forced': True, 'caching_enabled': True, 'block_exploits': True, 'allow_websocket_upgrade': True, 'http2_support': True, 'hsts_enabled': True, 'hsts_subdomains': True, 'meta': { 'letsencrypt_agree': True, 'dns_challenge': False } } response = requests.post( f"{nginx_settings['url'].rstrip('/')}/api/nginx/proxy-hosts", json=proxy_host_data, headers={ 'Authorization': f'Bearer {token}', 'Content-Type': 'application/json', 'Accept': 'application/json' }, timeout=5 ) if response.status_code == 200: return jsonify({ 'message': 'Proxy host created successfully', 'data': response.json() }) else: error_data = response.json() return jsonify({ 'error': f'Failed to create proxy host: {error_data.get("message", "Unknown error")}' }), 400 except Exception as e: return jsonify({'error': str(e)}), 500 @launch_api.route('/create-ssl-certificate', methods=['POST']) @csrf.exempt @token_required def create_ssl_certificate(current_user): try: data = request.get_json() current_app.logger.info(f"Received request data: {data}") domains = data.get('domains') proxy_host_id = data.get('proxy_host_id') nginx_url = data.get('nginx_url') current_app.logger.info(f"Extracted data - domains: {domains}, proxy_host_id: {proxy_host_id}, nginx_url: {nginx_url}") if not all([domains, proxy_host_id, nginx_url]): missing_fields = [] if not domains: missing_fields.append('domains') if not proxy_host_id: missing_fields.append('proxy_host_id') if not nginx_url: missing_fields.append('nginx_url') current_app.logger.error(f"Missing required fields: {missing_fields}") return jsonify({ 'success': False, 'error': f'Missing required fields: {", ".join(missing_fields)}' }), 400 # Get NGINX settings nginx_settings = KeyValueSettings.get_value('nginx_settings') if not nginx_settings: return jsonify({ 'success': False, 'error': 'NGINX settings not configured' }), 400 # First, get the JWT token token_response = requests.post( f"{nginx_settings['url'].rstrip('/')}/api/tokens", json={ 'identity': nginx_settings['username'], 'secret': nginx_settings['password'] }, headers={'Content-Type': 'application/json'}, timeout=5 ) if token_response.status_code != 200: return jsonify({ 'success': False, 'error': 'Failed to authenticate with NGINX Proxy Manager' }), 400 token_data = token_response.json() token = token_data.get('token') if not token: return jsonify({ 'success': False, 'error': 'No token received from NGINX Proxy Manager' }), 400 # Create the SSL certificate ssl_request_data = { 'provider': 'letsencrypt', 'domain_names': domains, 'meta': { 'letsencrypt_agree': True, 'dns_challenge': False } } current_app.logger.info(f"Making SSL certificate request to {nginx_url}/api/nginx/ssl with data: {ssl_request_data}") ssl_response = requests.post( f"{nginx_url}/api/nginx/ssl", headers={ 'Authorization': f'Bearer {token}', 'Content-Type': 'application/json', 'Accept': 'application/json' }, json=ssl_request_data ) current_app.logger.info(f"SSL certificate response status: {ssl_response.status_code}") current_app.logger.info(f"SSL certificate response headers: {dict(ssl_response.headers)}") if not ssl_response.ok: error_text = ssl_response.text current_app.logger.error(f"Failed to create SSL certificate: {error_text}") return jsonify({ 'success': False, 'error': f'Failed to create SSL certificate: {error_text}' }), ssl_response.status_code ssl_data = ssl_response.json() current_app.logger.info(f"SSL certificate created successfully: {ssl_data}") # Get the certificate ID cert_id = ssl_data.get('id') if not cert_id: current_app.logger.error("No certificate ID received in response") return jsonify({ 'success': False, 'error': 'No certificate ID received' }), 500 # Update the proxy host with the certificate update_request_data = { 'ssl_certificate_id': cert_id } current_app.logger.info(f"Updating proxy host {proxy_host_id} with data: {update_request_data}") update_response = requests.put( f"{nginx_url}/api/nginx/proxy-hosts/{proxy_host_id}", headers={ 'Authorization': f'Bearer {token}', 'Content-Type': 'application/json', 'Accept': 'application/json' }, json=update_request_data ) current_app.logger.info(f"Update response status: {update_response.status_code}") current_app.logger.info(f"Update response headers: {dict(update_response.headers)}") if not update_response.ok: error_text = update_response.text current_app.logger.error(f"Failed to update proxy host: {error_text}") return jsonify({ 'success': False, 'error': f'Failed to update proxy host: {error_text}' }), update_response.status_code update_data = update_response.json() current_app.logger.info(f"Proxy host updated successfully: {update_data}") return jsonify({ 'success': True, 'data': { 'certificate': ssl_data, 'proxy_host': update_data } }) except Exception as e: current_app.logger.error(f"Error in create_ssl_certificate: {str(e)}") return jsonify({ 'success': False, 'error': str(e) }), 500 @launch_api.route('/download-docker-compose', methods=['POST']) @csrf.exempt def download_docker_compose(): """Download docker-compose.yml from the repository""" data = request.get_json() if not data or 'repository' not in data or 'branch' not in data: return jsonify({'message': 'Missing required fields'}), 400 try: # Get Git settings git_settings = KeyValueSettings.get_value('git_settings') if not git_settings: return jsonify({'message': 'Git settings not configured'}), 400 # Get the current commit hash and latest tag for the branch commit_hash = None latest_tag = None if git_settings['provider'] == 'gitea': headers = { 'Accept': 'application/json', 'Authorization': f'token {git_settings["token"]}' } # Get the latest commit for the branch commit_response = requests.get( f'{git_settings["url"]}/api/v1/repos/{data["repository"]}/commits/{data["branch"]}', headers=headers ) if commit_response.status_code == 200: commit_data = commit_response.json() commit_hash = commit_data.get('sha') else: # Try token as query parameter if header auth fails commit_response = requests.get( f'{git_settings["url"]}/api/v1/repos/{data["repository"]}/commits/{data["branch"]}?token={git_settings["token"]}', headers={'Accept': 'application/json'} ) if commit_response.status_code == 200: commit_data = commit_response.json() commit_hash = commit_data.get('sha') # Get the latest tag tags_response = requests.get( f'{git_settings["url"]}/api/v1/repos/{data["repository"]}/tags', headers=headers ) if tags_response.status_code == 200: tags_data = tags_response.json() if tags_data: # Sort tags by commit date (newest first) and get the latest sorted_tags = sorted(tags_data, key=lambda x: x.get('commit', {}).get('created', ''), reverse=True) if sorted_tags: latest_tag = sorted_tags[0].get('name') else: # Try token as query parameter if header auth fails tags_response = requests.get( f'{git_settings["url"]}/api/v1/repos/{data["repository"]}/tags?token={git_settings["token"]}', headers={'Accept': 'application/json'} ) if tags_response.status_code == 200: tags_data = tags_response.json() if tags_data: sorted_tags = sorted(tags_data, key=lambda x: x.get('commit', {}).get('created', ''), reverse=True) if sorted_tags: latest_tag = sorted_tags[0].get('name') # Determine the provider and set up the appropriate API call if git_settings['provider'] == 'gitea': # For Gitea headers = { 'Accept': 'application/json', 'Authorization': f'token {git_settings["token"]}' } # Try to get the file content response = requests.get( f'{git_settings["url"]}/api/v1/repos/{data["repository"]}/contents/docker-compose.yml', headers=headers, params={'ref': data['branch']} ) if response.status_code != 200: # Try token as query parameter if header auth fails response = requests.get( f'{git_settings["url"]}/api/v1/repos/{data["repository"]}/contents/docker-compose.yml?token={git_settings["token"]}', headers={'Accept': 'application/json'}, params={'ref': data['branch']} ) elif git_settings['provider'] == 'gitlab': # For GitLab headers = { 'PRIVATE-TOKEN': git_settings['token'], 'Accept': 'application/json' } # Get the file content response = requests.get( f'{git_settings["url"]}/api/v4/projects/{data["repository"].replace("/", "%2F")}/repository/files/docker-compose.yml/raw', headers=headers, params={'ref': data['branch']} ) else: return jsonify({'message': 'Unsupported Git provider'}), 400 if response.status_code == 200: # For Gitea, we need to decode the content from base64 if git_settings['provider'] == 'gitea': content = base64.b64decode(response.json()['content']).decode('utf-8') else: content = response.text return jsonify({ 'success': True, 'content': content, 'commit_hash': commit_hash, 'latest_tag': latest_tag }) else: return jsonify({ 'message': f'Failed to download docker-compose.yml: {response.json().get("message", "Unknown error")}' }), 400 except Exception as e: current_app.logger.error(f"Error downloading docker-compose.yml: {str(e)}") return jsonify({'message': f'Error downloading docker-compose.yml: {str(e)}'}), 500 @launch_api.route('/deploy-stack', methods=['POST']) @csrf.exempt def deploy_stack(): try: data = request.get_json() if not data or 'name' not in data or 'StackFileContent' not in data: return jsonify({'error': 'Missing required fields'}), 400 # Get Portainer settings portainer_settings = KeyValueSettings.get_value('portainer_settings') if not portainer_settings: return jsonify({'error': 'Portainer settings not configured'}), 400 # Define timeout early to ensure it's available throughout the function stack_timeout = current_app.config.get('STACK_DEPLOYMENT_TIMEOUT', 300) # Default to 5 minutes # Verify Portainer authentication auth_response = requests.get( f"{portainer_settings['url'].rstrip('/')}/api/status", headers={ 'X-API-Key': portainer_settings['api_key'], 'Accept': 'application/json' }, timeout=30 # 30 seconds timeout for status check ) if not auth_response.ok: current_app.logger.error(f"Portainer authentication failed: {auth_response.text}") return jsonify({'error': 'Failed to authenticate with Portainer'}), 401 # Get Portainer endpoint ID (assuming it's the first endpoint) endpoint_response = requests.get( f"{portainer_settings['url'].rstrip('/')}/api/endpoints", headers={ 'X-API-Key': portainer_settings['api_key'], 'Accept': 'application/json' }, timeout=30 # 30 seconds timeout for endpoint check ) if not endpoint_response.ok: error_text = endpoint_response.text try: error_json = endpoint_response.json() error_text = error_json.get('message', error_text) except: pass return jsonify({'error': f'Failed to get Portainer endpoints: {error_text}'}), 500 endpoints = endpoint_response.json() if not endpoints: return jsonify({'error': 'No Portainer endpoints found'}), 400 endpoint_id = endpoints[0]['Id'] # Log the request data current_app.logger.info(f"Creating stack with data: {json.dumps(data)}") current_app.logger.info(f"Using endpoint ID: {endpoint_id}") current_app.logger.info(f"Using timeout: {stack_timeout} seconds") # First, check if a stack with this name already exists stacks_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks" stacks_response = requests.get( stacks_url, headers={ 'X-API-Key': portainer_settings['api_key'], 'Accept': 'application/json' }, params={'filters': json.dumps({'Name': data['name']})}, timeout=30 ) if stacks_response.ok: existing_stacks = stacks_response.json() for stack in existing_stacks: if stack['Name'] == data['name']: current_app.logger.info(f"Found existing stack: {stack['Name']} (ID: {stack['Id']})") return jsonify({ 'success': True, 'data': { 'name': stack['Name'], 'id': stack['Id'], 'status': 'existing' } }) # If no existing stack found, proceed with creation url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/create/standalone/string" current_app.logger.info(f"Making request to: {url}") # Prepare the request body according to Portainer's API spec request_body = data # Add endpointId as a query parameter params = {'endpointId': endpoint_id} # Use a configurable timeout for stack creation initiation create_response = requests.post( url, headers={ 'X-API-Key': portainer_settings['api_key'], 'Content-Type': 'application/json', 'Accept': 'application/json' }, params=params, json=request_body, timeout=stack_timeout # Use configurable timeout ) # Log the response details current_app.logger.info(f"Response status: {create_response.status_code}") current_app.logger.info(f"Response headers: {dict(create_response.headers)}") response_text = create_response.text current_app.logger.info(f"Response body: {response_text}") if not create_response.ok: error_message = response_text try: error_json = create_response.json() error_message = error_json.get('message', error_message) except: pass return jsonify({'error': f'Failed to create stack: {error_message}'}), 500 stack_info = create_response.json() current_app.logger.info(f"Stack creation initiated: {stack_info['Name']} (ID: {stack_info['Id']})") return jsonify({ 'success': True, 'data': { 'name': stack_info['Name'], 'id': stack_info['Id'], 'status': 'creating' } }) except requests.exceptions.Timeout: current_app.logger.error(f"Request timed out after {stack_timeout} seconds while initiating stack deployment") current_app.logger.error(f"Stack name: {data.get('name', 'unknown') if 'data' in locals() else 'unknown'}") current_app.logger.error(f"Portainer URL: {portainer_settings.get('url', 'unknown') if 'portainer_settings' in locals() else 'unknown'}") return jsonify({ 'error': f'Request timed out after {stack_timeout} seconds while initiating stack deployment. The operation may still be in progress.', 'timeout_seconds': stack_timeout, 'stack_name': data.get('name', 'unknown') if 'data' in locals() else 'unknown' }), 504 except Exception as e: current_app.logger.error(f"Error deploying stack: {str(e)}") return jsonify({'error': str(e)}), 500 @launch_api.route('/check-stack-status', methods=['POST']) @csrf.exempt def check_stack_status(): try: data = request.get_json() if not data or ('stack_name' not in data and 'stack_id' not in data): return jsonify({'error': 'Missing stack_name or stack_id field'}), 400 # Get Portainer settings portainer_settings = KeyValueSettings.get_value('portainer_settings') if not portainer_settings: return jsonify({'error': 'Portainer settings not configured'}), 400 # Get Portainer endpoint ID (assuming it's the first endpoint) endpoint_response = requests.get( f"{portainer_settings['url'].rstrip('/')}/api/endpoints", headers={ 'X-API-Key': portainer_settings['api_key'], 'Accept': 'application/json' }, timeout=30 ) if not endpoint_response.ok: return jsonify({'error': 'Failed to get Portainer endpoints'}), 500 endpoints = endpoint_response.json() if not endpoints: return jsonify({'error': 'No Portainer endpoints found'}), 400 endpoint_id = endpoints[0]['Id'] # Get stack information - support both stack_name and stack_id if 'stack_id' in data: # Get stack by ID stack_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{data['stack_id']}" stack_response = requests.get( stack_url, headers={ 'X-API-Key': portainer_settings['api_key'], 'Accept': 'application/json' }, params={'endpointId': endpoint_id}, timeout=30 ) if not stack_response.ok: return jsonify({'error': f'Stack with ID {data["stack_id"]} not found'}), 404 target_stack = stack_response.json() else: # Get stack by name (existing logic) stacks_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks" stacks_response = requests.get( stacks_url, headers={ 'X-API-Key': portainer_settings['api_key'], 'Accept': 'application/json' }, params={'filters': json.dumps({'Name': data['stack_name']})}, timeout=30 ) if not stacks_response.ok: return jsonify({'error': 'Failed to get stack information'}), 500 stacks = stacks_response.json() target_stack = None for stack in stacks: if stack['Name'] == data['stack_name']: target_stack = stack break if not target_stack: return jsonify({'error': f'Stack {data["stack_name"]} not found'}), 404 # Get stack services to check their status services_url = f"{portainer_settings['url'].rstrip('/')}/api/endpoints/{endpoint_id}/docker/services" current_app.logger.info(f"Checking services for stack {target_stack['Name']} at endpoint {endpoint_id}") try: services_response = requests.get( services_url, headers={ 'X-API-Key': portainer_settings['api_key'], 'Accept': 'application/json' }, params={'filters': json.dumps({'label': f'com.docker.stack.namespace={target_stack["Name"]}'})}, timeout=30 ) current_app.logger.info(f"Services API response status: {services_response.status_code}") if services_response.ok: services = services_response.json() service_statuses = [] for service in services: service_statuses.append({ 'name': service.get('Spec', {}).get('Name', 'Unknown'), 'replicas': service.get('Spec', {}).get('Mode', {}).get('Replicated', {}).get('Replicas', 0), 'running_replicas': service.get('ServiceStatus', {}).get('RunningTasks', 0), 'desired_replicas': service.get('ServiceStatus', {}).get('DesiredTasks', 0) }) # Determine overall stack status if not service_statuses: status = 'starting' # No services found yet else: all_running = all(s['running_replicas'] >= s['desired_replicas'] for s in service_statuses if s['desired_replicas'] > 0) any_running = any(s['running_replicas'] > 0 for s in service_statuses) if all_running: status = 'active' elif any_running: status = 'partial' else: status = 'inactive' else: # Services API failed, but stack exists - assume it's still starting up current_app.logger.warning(f"Failed to get services for stack {target_stack['Name']}: {services_response.status_code} - {services_response.text}") # Provide more specific error context if services_response.status_code == 404: current_app.logger.info(f"Services endpoint not found for stack {target_stack['Name']} - stack may still be initializing") elif services_response.status_code == 403: current_app.logger.warning(f"Access denied to services for stack {target_stack['Name']} - check Portainer permissions") elif services_response.status_code >= 500: current_app.logger.warning(f"Portainer server error when getting services for stack {target_stack['Name']}") services = [] service_statuses = [] status = 'starting' # Stack exists but services not available yet except Exception as e: # Exception occurred while getting services, but stack exists current_app.logger.warning(f"Exception getting services for stack {target_stack['Name']}: {str(e)}") services = [] service_statuses = [] status = 'starting' # Stack exists but services not available yet return jsonify({ 'success': True, 'data': { 'name': target_stack['Name'], 'stack_id': target_stack['Id'], 'status': status, 'services': service_statuses } }) except Exception as e: current_app.logger.error(f"Error checking stack status: {str(e)}") return jsonify({'error': str(e)}), 500 @launch_api.route('/save-instance', methods=['POST']) @csrf.exempt def save_instance(): try: if not request.is_json: return jsonify({'error': 'Request must be JSON'}), 400 data = request.get_json() required_fields = ['name', 'port', 'domains', 'stack_id', 'stack_name', 'status', 'repository', 'branch'] if not all(field in data for field in required_fields): missing_fields = [field for field in required_fields if field not in data] return jsonify({'error': f'Missing required fields: {", ".join(missing_fields)}'}), 400 # Check if instance already exists existing_instance = Instance.query.filter_by(name=data['name']).first() if existing_instance: # Update existing instance existing_instance.portainer_stack_id = data['stack_id'] existing_instance.portainer_stack_name = data['stack_name'] existing_instance.status = data['status'] existing_instance.deployed_version = data.get('deployed_version', 'unknown') existing_instance.deployed_branch = data.get('deployed_branch', data['branch']) existing_instance.version_checked_at = datetime.utcnow() db.session.commit() return jsonify({ 'message': 'Instance data updated successfully', 'data': { 'name': existing_instance.name, 'portainer_stack_id': existing_instance.portainer_stack_id, 'portainer_stack_name': existing_instance.portainer_stack_name, 'status': existing_instance.status, 'deployed_version': existing_instance.deployed_version, 'deployed_branch': existing_instance.deployed_branch } }) else: # Create new instance instance = Instance( name=data['name'], company='Loading...', # Will be updated later rooms_count=0, conversations_count=0, data_size=0.0, payment_plan=data.get('payment_plan', 'Basic'), main_url=f"https://{data['domains'][0]}" if data['domains'] else f"http://localhost:{data['port']}", status=data['status'], portainer_stack_id=data['stack_id'], portainer_stack_name=data['stack_name'], deployed_version=data.get('deployed_version', 'unknown'), deployed_branch=data.get('deployed_branch', data['branch']) ) db.session.add(instance) db.session.commit() return jsonify({ 'message': 'Instance data saved successfully', 'data': { 'name': instance.name, 'portainer_stack_id': instance.portainer_stack_id, 'portainer_stack_name': instance.portainer_stack_name, 'status': instance.status, 'deployed_version': instance.deployed_version, 'deployed_branch': instance.deployed_branch } }) except Exception as e: current_app.logger.error(f"Error saving instance data: {str(e)}") return jsonify({'error': str(e)}), 500 @launch_api.route('/apply-company-information', methods=['POST']) @csrf.exempt def apply_company_information(): """Apply company information to a launched instance""" try: if not request.is_json: return jsonify({'error': 'Request must be JSON'}), 400 data = request.get_json() required_fields = ['instance_url', 'company_data'] if not all(field in data for field in required_fields): missing_fields = [field for field in required_fields if field not in data] return jsonify({'error': f'Missing required fields: {", ".join(missing_fields)}'}), 400 instance_url = data['instance_url'] company_data = data['company_data'] # Get the instance from database to get the connection token instance = Instance.query.filter_by(main_url=instance_url).first() if not instance: return jsonify({'error': 'Instance not found in database'}), 404 if not instance.connection_token: return jsonify({'error': 'Instance not authenticated'}), 400 # First get JWT token from the instance jwt_response = requests.post( f"{instance_url.rstrip('/')}/api/admin/management-token", headers={ 'X-API-Key': instance.connection_token, 'Accept': 'application/json' }, timeout=10 ) if jwt_response.status_code != 200: return jsonify({'error': f'Failed to get JWT token: {jwt_response.text}'}), 400 jwt_data = jwt_response.json() jwt_token = jwt_data.get('token') if not jwt_token: return jsonify({'error': 'No JWT token received'}), 400 # Prepare company data for the API api_company_data = { 'company_name': company_data.get('name'), 'company_industry': company_data.get('industry'), 'company_email': company_data.get('email'), 'company_website': company_data.get('website'), 'company_address': company_data.get('streetAddress'), 'company_city': company_data.get('city'), 'company_state': company_data.get('state'), 'company_zip': company_data.get('zipCode'), 'company_country': company_data.get('country'), 'company_description': company_data.get('description'), 'company_phone': company_data.get('phone') } # Apply company information to the instance company_response = requests.put( f"{instance_url.rstrip('/')}/api/admin/settings", headers={ 'Authorization': f'Bearer {jwt_token}', 'Content-Type': 'application/json', 'Accept': 'application/json' }, json=api_company_data, timeout=10 ) if company_response.status_code != 200: return jsonify({'error': f'Failed to apply company information: {company_response.text}'}), 400 # Update the instance company name in our database instance.company = company_data.get('name', 'Unknown') db.session.commit() return jsonify({ 'message': 'Company information applied successfully', 'data': { 'company_name': company_data.get('name'), 'instance_url': instance_url } }) except Exception as e: current_app.logger.error(f"Error applying company information: {str(e)}") return jsonify({'error': str(e)}), 500 @launch_api.route('/apply-colors', methods=['POST']) @csrf.exempt def apply_colors(): """Apply colors to a launched instance""" try: if not request.is_json: return jsonify({'error': 'Request must be JSON'}), 400 data = request.get_json() required_fields = ['instance_url', 'colors_data'] if not all(field in data for field in required_fields): missing_fields = [field for field in required_fields if field not in data] return jsonify({'error': f'Missing required fields: {", ".join(missing_fields)}'}), 400 instance_url = data['instance_url'] colors_data = data['colors_data'] # Get the instance from database to get the connection token instance = Instance.query.filter_by(main_url=instance_url).first() if not instance: return jsonify({'error': 'Instance not found in database'}), 404 if not instance.connection_token: return jsonify({'error': 'Instance not authenticated'}), 400 # First get JWT token from the instance jwt_response = requests.post( f"{instance_url.rstrip('/')}/api/admin/management-token", headers={ 'X-API-Key': instance.connection_token, 'Accept': 'application/json' }, timeout=10 ) if jwt_response.status_code != 200: return jsonify({'error': f'Failed to get JWT token: {jwt_response.text}'}), 400 jwt_data = jwt_response.json() jwt_token = jwt_data.get('token') if not jwt_token: return jsonify({'error': 'No JWT token received'}), 400 # Prepare colors data for the API api_colors_data = { 'primary_color': colors_data.get('primary'), 'secondary_color': colors_data.get('secondary') } # Apply colors to the instance colors_response = requests.put( f"{instance_url.rstrip('/')}/api/admin/settings", headers={ 'Authorization': f'Bearer {jwt_token}', 'Content-Type': 'application/json', 'Accept': 'application/json' }, json=api_colors_data, timeout=10 ) if colors_response.status_code != 200: return jsonify({'error': f'Failed to apply colors: {colors_response.text}'}), 400 return jsonify({ 'message': 'Colors applied successfully', 'data': { 'primary_color': colors_data.get('primary'), 'secondary_color': colors_data.get('secondary'), 'instance_url': instance_url } }) except Exception as e: current_app.logger.error(f"Error applying colors: {str(e)}") return jsonify({'error': str(e)}), 500 @launch_api.route('/update-admin-credentials', methods=['POST']) @csrf.exempt def update_admin_credentials(): """Update admin credentials on a launched instance""" try: if not request.is_json: return jsonify({'error': 'Request must be JSON'}), 400 data = request.get_json() required_fields = ['instance_url', 'email'] if not all(field in data for field in required_fields): missing_fields = [field for field in required_fields if field not in data] return jsonify({'error': f'Missing required fields: {", ".join(missing_fields)}'}), 400 instance_url = data['instance_url'] email = data['email'] # Get the instance from database to get the connection token instance = Instance.query.filter_by(main_url=instance_url).first() if not instance: return jsonify({'error': 'Instance not found in database'}), 404 if not instance.connection_token: return jsonify({'error': 'Instance not authenticated'}), 400 # First get JWT token from the instance jwt_response = requests.post( f"{instance_url.rstrip('/')}/api/admin/management-token", headers={ 'X-API-Key': instance.connection_token, 'Accept': 'application/json' }, timeout=10 ) if jwt_response.status_code != 200: return jsonify({'error': f'Failed to get JWT token: {jwt_response.text}'}), 400 jwt_data = jwt_response.json() jwt_token = jwt_data.get('token') if not jwt_token: return jsonify({'error': 'No JWT token received'}), 400 # Get the admin user ID first users_response = requests.get( f"{instance_url.rstrip('/')}/api/admin/contacts", headers={ 'Authorization': f'Bearer {jwt_token}', 'Accept': 'application/json' }, timeout=10 ) if users_response.status_code != 200: return jsonify({'error': f'Failed to get users: {users_response.text}'}), 400 users_data = users_response.json() admin_user = None # Find the administrator user by role (since email was already updated) for user in users_data: if user.get('is_admin') == True: admin_user = user break if not admin_user: return jsonify({'error': 'Administrator user not found'}), 404 admin_user_id = admin_user.get('id') # Try to login with default credentials first try: login_response = requests.post( f"{instance_url.rstrip('/')}/api/admin/login", headers={ 'Content-Type': 'application/json', 'Accept': 'application/json' }, json={ 'email': 'administrator@docupulse.com', 'password': 'changeme' }, timeout=10 ) # If login with default credentials succeeds, update the credentials if login_response.status_code == 200: login_data = login_response.json() if login_data.get('status') == 'success' and login_data.get('token'): admin_token = login_data.get('token') # Generate a secure password import secrets import string alphabet = string.ascii_letters + string.digits + "!@#$%^&*" new_password = ''.join(secrets.choice(alphabet) for i in range(16)) # Update the admin user with new email and password update_response = requests.put( f"{instance_url.rstrip('/')}/api/admin/contacts/{admin_user_id}", headers={ 'Authorization': f'Bearer {admin_token}', 'Content-Type': 'application/json', 'Accept': 'application/json' }, json={ 'email': email, 'password': new_password, 'username': 'administrator', 'last_name': 'Administrator', 'role': 'admin' }, timeout=10 ) if update_response.status_code != 200: return jsonify({'error': f'Failed to update admin credentials: {update_response.text}'}), 400 return jsonify({ 'message': 'Admin credentials updated successfully', 'data': { 'email': email, 'password': new_password, 'username': 'administrator', 'instance_url': instance_url } }) # If login with default credentials fails, check if email is already updated else: # Check if the email is already the target email if admin_user.get('email') == email: return jsonify({ 'message': 'Admin credentials already updated', 'data': { 'email': email, 'password': 'Already set', 'username': 'administrator', 'instance_url': instance_url, 'already_updated': True } }) else: return jsonify({'error': 'Failed to login with default credentials and email not yet updated'}), 400 except Exception as login_error: # If there's an error with login, check if email is already updated if admin_user.get('email') == email: return jsonify({ 'message': 'Admin credentials already updated', 'data': { 'email': email, 'password': 'Already set', 'username': 'administrator', 'instance_url': instance_url, 'already_updated': True } }) else: return jsonify({'error': f'Login error and email not yet updated: {str(login_error)}'}), 400 except Exception as e: current_app.logger.error(f"Error updating admin credentials: {str(e)}") return jsonify({'error': str(e)}), 500 @launch_api.route('/send-completion-email', methods=['POST']) @csrf.exempt def send_completion_email(): """Send completion email to client with instance details and password reset link""" try: if not request.is_json: return jsonify({'error': 'Request must be JSON'}), 400 data = request.get_json() required_fields = ['instance_url', 'company_data', 'credentials_data'] if not all(field in data for field in required_fields): missing_fields = [field for field in required_fields if field not in data] return jsonify({'error': f'Missing required fields: {", ".join(missing_fields)}'}), 400 instance_url = data['instance_url'] company_data = data['company_data'] credentials_data = data['credentials_data'] # Get SMTP settings from master instance smtp_settings = KeyValueSettings.get_value('smtp_settings') if not smtp_settings: return jsonify({'error': 'SMTP settings not configured'}), 400 # Get the instance from database to get the connection token instance = Instance.query.filter_by(main_url=instance_url).first() if not instance: return jsonify({'error': 'Instance not found in database'}), 404 if not instance.connection_token: return jsonify({'error': 'Instance not authenticated'}), 400 # Get JWT token from the launched instance using management API key jwt_response = requests.post( f"{instance_url.rstrip('/')}/api/admin/management-token", headers={ 'X-API-Key': instance.connection_token, 'Accept': 'application/json' }, timeout=10 ) if jwt_response.status_code != 200: return jsonify({'error': f'Failed to get JWT token: {jwt_response.text}'}), 400 jwt_data = jwt_response.json() jwt_token = jwt_data.get('token') if not jwt_token: return jsonify({'error': 'No JWT token received'}), 400 # Get the admin user ID from the launched instance users_response = requests.get( f"{instance_url.rstrip('/')}/api/admin/contacts", headers={ 'Authorization': f'Bearer {jwt_token}', 'Accept': 'application/json' }, timeout=10 ) if users_response.status_code != 200: return jsonify({'error': f'Failed to get users: {users_response.text}'}), 400 users_data = users_response.json() admin_user = None # Find the administrator user by role (since email was already updated) for user in users_data: if user.get('is_admin') == True: admin_user = user break if not admin_user: return jsonify({'error': 'Administrator user not found'}), 404 admin_user_id = admin_user.get('id') # Generate password reset token for the launched instance using management API reset_response = requests.post( f"{instance_url.rstrip('/')}/api/admin/generate-password-reset/{admin_user_id}", headers={ 'Authorization': f'Bearer {jwt_token}', 'Accept': 'application/json', 'Content-Type': 'application/json' }, json={'instance_url': instance_url}, timeout=10 ) if reset_response.status_code != 200: return jsonify({'error': f'Failed to generate password reset: {reset_response.text}'}), 400 reset_data = reset_response.json() reset_url = reset_data.get('reset_url') expires_at = reset_data.get('expires_at') if not reset_url: return jsonify({'error': 'No reset URL received from instance'}), 400 # Create email content subject = "Your DocuPulse Instance is Ready!" # Build HTML email content html_content = f"""

🎉 Your DocuPulse Instance is Ready!

Dear {company_data.get('name', 'Valued Customer')},

Great news! Your DocuPulse instance has been successfully deployed and configured. You can now access your secure document management platform.

📋 Instance Details

Instance URL: {instance_url}

Company Name: {company_data.get('name', 'Not set')}

Industry: {company_data.get('industry', 'Not set')}

Deployment Date: {datetime.utcnow().strftime('%B %d, %Y at %I:%M %p UTC')}

🔐 Account Access

Email Address: {admin_user.get('email', 'Not set')}

Username: {admin_user.get('username', 'administrator')}

🔒 Security Setup Required

For your security, you need to set up your password. Click the button below to create your secure password.

Password Reset Link Expires: {expires_at}

🔐 Set Up Your Password

🚀 Access Your Instance

✅ What's Been Configured

🎯 Next Steps

  1. Click the "Set Up Your Password" button above
  2. Create your secure password
  3. Return to your instance and log in
  4. Explore your new DocuPulse platform
  5. Start uploading and organizing your documents
  6. Invite team members to collaborate
""" # Build plain text version text_content = f""" Your DocuPulse Instance is Ready! Dear {company_data.get('name', 'Valued Customer')}, Great news! Your DocuPulse instance has been successfully deployed and configured. INSTANCE DETAILS: - Instance URL: {instance_url} - Company Name: {company_data.get('name', 'Not set')} - Industry: {company_data.get('industry', 'Not set')} - Deployment Date: {datetime.utcnow().strftime('%B %d, %Y at %I:%M %p UTC')} ACCOUNT ACCESS: - Email Address: {admin_user.get('email', 'Not set')} - Username: {admin_user.get('username', 'administrator')} SECURITY SETUP REQUIRED: For your security, you need to set up your password. Password Reset Link: {reset_url} Password Reset Link Expires: {expires_at} WHAT'S BEEN CONFIGURED: ✓ Secure SSL certificate for HTTPS access ✓ Company information and branding ✓ Custom color scheme ✓ Admin account created ✓ Document management system ready NEXT STEPS: 1. Click the password reset link above 2. Create your secure password 3. Return to your instance and log in 4. Explore your new DocuPulse platform 5. Start uploading and organizing your documents 6. Invite team members to collaborate If you have any questions or need assistance, please don't hesitate to contact our support team. Thank you for choosing DocuPulse! """ # Send email using master instance's email system try: # Get SMTP settings smtp_settings = KeyValueSettings.get_value('smtp_settings') if not smtp_settings: return jsonify({'error': 'SMTP settings not configured'}), 400 # Create message msg = MIMEMultipart() msg['From'] = f"{smtp_settings.get('smtp_from_name', 'DocuPulse')} <{smtp_settings.get('smtp_from_email')}>" msg['To'] = company_data.get('email') msg['Subject'] = subject msg['Date'] = formatdate(localtime=True) # Add HTML content msg.attach(MIMEText(html_content, 'html')) # Send email if smtp_settings.get('smtp_security') == 'ssl': server = smtplib.SMTP_SSL(smtp_settings.get('smtp_host'), smtp_settings.get('smtp_port')) else: server = smtplib.SMTP(smtp_settings.get('smtp_host'), smtp_settings.get('smtp_port')) if smtp_settings.get('smtp_security') == 'tls': server.starttls() if smtp_settings.get('smtp_username') and smtp_settings.get('smtp_password'): server.login(smtp_settings.get('smtp_username'), smtp_settings.get('smtp_password')) server.send_message(msg) server.quit() # Log the email sending current_app.logger.info(f"Completion email sent to {company_data.get('email')} for instance {instance_url}") return jsonify({ 'message': 'Completion email sent successfully', 'data': { 'recipient': company_data.get('email'), 'subject': subject, 'instance_url': instance_url, 'password_reset_sent': True } }) except Exception as email_error: current_app.logger.error(f"Failed to send completion email: {str(email_error)}") return jsonify({'error': f'Failed to send email: {str(email_error)}'}), 500 except Exception as e: current_app.logger.error(f"Error sending completion email: {str(e)}") return jsonify({'error': str(e)}), 500 @launch_api.route('/copy-smtp-settings', methods=['POST']) @csrf.exempt def copy_smtp_settings(): """Copy SMTP settings from master instance to launched instance""" try: if not request.is_json: return jsonify({'error': 'Request must be JSON'}), 400 data = request.get_json() if 'instance_url' not in data: return jsonify({'error': 'Missing instance_url parameter'}), 400 instance_url = data['instance_url'] # Get SMTP settings from master instance smtp_settings = KeyValueSettings.get_value('smtp_settings') if not smtp_settings: return jsonify({'error': 'SMTP settings not configured on master instance'}), 400 # Get the instance from database to get the connection token instance = Instance.query.filter_by(main_url=instance_url).first() if not instance: return jsonify({'error': 'Instance not found in database'}), 404 if not instance.connection_token: return jsonify({'error': 'Instance not authenticated'}), 400 # Get JWT token from the launched instance using management API key jwt_response = requests.post( f"{instance_url.rstrip('/')}/api/admin/management-token", headers={ 'X-API-Key': instance.connection_token, 'Accept': 'application/json' }, timeout=10 ) if jwt_response.status_code != 200: return jsonify({'error': f'Failed to get JWT token: {jwt_response.text}'}), 400 jwt_data = jwt_response.json() jwt_token = jwt_data.get('token') if not jwt_token: return jsonify({'error': 'No JWT token received'}), 400 # Prepare SMTP settings data for the API api_smtp_data = { 'smtp_host': smtp_settings.get('smtp_host'), 'smtp_port': smtp_settings.get('smtp_port'), 'smtp_username': smtp_settings.get('smtp_username'), 'smtp_password': smtp_settings.get('smtp_password'), 'smtp_security': smtp_settings.get('smtp_security'), 'smtp_from_email': smtp_settings.get('smtp_from_email'), 'smtp_from_name': smtp_settings.get('smtp_from_name') } # Copy SMTP settings to the launched instance smtp_response = requests.put( f"{instance_url.rstrip('/')}/api/admin/settings", headers={ 'Authorization': f'Bearer {jwt_token}', 'Accept': 'application/json', 'Content-Type': 'application/json' }, json=api_smtp_data, timeout=10 ) if smtp_response.status_code != 200: return jsonify({'error': f'Failed to copy SMTP settings: {smtp_response.text}'}), 400 # Log the SMTP settings copy current_app.logger.info(f"SMTP settings copied to instance {instance_url}") return jsonify({ 'message': 'SMTP settings copied successfully', 'data': api_smtp_data }) except Exception as e: current_app.logger.error(f"Error copying SMTP settings: {str(e)}") return jsonify({'error': str(e)}), 500 @launch_api.route('/update-stack', methods=['POST']) @csrf.exempt def update_stack(): try: data = request.get_json() if not data or 'stack_id' not in data or 'StackFileContent' not in data: return jsonify({'error': 'Missing required fields'}), 400 # Get Portainer settings portainer_settings = KeyValueSettings.get_value('portainer_settings') if not portainer_settings: return jsonify({'error': 'Portainer settings not configured'}), 400 # Define timeout early to ensure it's available throughout the function stack_timeout = current_app.config.get('STACK_DEPLOYMENT_TIMEOUT', 300) # Default to 5 minutes # Verify Portainer authentication auth_response = requests.get( f"{portainer_settings['url'].rstrip('/')}/api/status", headers={ 'X-API-Key': portainer_settings['api_key'], 'Accept': 'application/json' }, timeout=30 # 30 seconds timeout for status check ) if not auth_response.ok: current_app.logger.error(f"Portainer authentication failed: {auth_response.text}") return jsonify({'error': 'Failed to authenticate with Portainer'}), 401 # Get Portainer endpoint ID (assuming it's the first endpoint) endpoint_response = requests.get( f"{portainer_settings['url'].rstrip('/')}/api/endpoints", headers={ 'X-API-Key': portainer_settings['api_key'], 'Accept': 'application/json' }, timeout=30 # 30 seconds timeout for endpoint check ) if not endpoint_response.ok: error_text = endpoint_response.text try: error_json = endpoint_response.json() error_text = error_json.get('message', error_text) except: pass return jsonify({'error': f'Failed to get Portainer endpoints: {error_text}'}), 500 endpoints = endpoint_response.json() if not endpoints: return jsonify({'error': 'No Portainer endpoints found'}), 400 endpoint_id = endpoints[0]['Id'] # Log the request data current_app.logger.info(f"Updating stack with ID: {data['stack_id']}") current_app.logger.info(f"Using endpoint ID: {endpoint_id}") current_app.logger.info(f"Using timeout: {stack_timeout} seconds") # First, verify the stack exists stack_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{data['stack_id']}" stack_response = requests.get( stack_url, headers={ 'X-API-Key': portainer_settings['api_key'], 'Accept': 'application/json' }, params={'endpointId': endpoint_id}, timeout=30 ) if not stack_response.ok: return jsonify({'error': f'Stack with ID {data["stack_id"]} not found'}), 404 stack_info = stack_response.json() current_app.logger.info(f"Found existing stack: {stack_info['Name']} (ID: {stack_info['Id']})") # Update the stack using Portainer's update API update_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{data['stack_id']}/update" current_app.logger.info(f"Making update request to: {update_url}") # Prepare the request body for stack update request_body = { 'StackFileContent': data['StackFileContent'], 'Env': data.get('Env', []) } # Add endpointId as a query parameter params = {'endpointId': endpoint_id} # Use a configurable timeout for stack update initiation update_response = requests.put( update_url, headers={ 'X-API-Key': portainer_settings['api_key'], 'Content-Type': 'application/json', 'Accept': 'application/json' }, params=params, json=request_body, timeout=stack_timeout # Use configurable timeout ) # Log the response details current_app.logger.info(f"Update response status: {update_response.status_code}") current_app.logger.info(f"Update response headers: {dict(update_response.headers)}") response_text = update_response.text current_app.logger.info(f"Update response body: {response_text}") if not update_response.ok: error_message = response_text try: error_json = update_response.json() error_message = error_json.get('message', error_message) except: pass return jsonify({'error': f'Failed to update stack: {error_message}'}), 500 # Stack update initiated successfully current_app.logger.info(f"Stack update initiated: {stack_info['Name']} (ID: {stack_info['Id']})") return jsonify({ 'success': True, 'data': { 'name': stack_info['Name'], 'id': stack_info['Id'], 'status': 'updating' } }) except requests.exceptions.Timeout: current_app.logger.error(f"Request timed out after {stack_timeout} seconds while initiating stack update") current_app.logger.error(f"Stack ID: {data.get('stack_id', 'unknown') if 'data' in locals() else 'unknown'}") current_app.logger.error(f"Portainer URL: {portainer_settings.get('url', 'unknown') if 'portainer_settings' in locals() else 'unknown'}") return jsonify({ 'error': f'Request timed out after {stack_timeout} seconds while initiating stack update. The operation may still be in progress.', 'timeout_seconds': stack_timeout, 'stack_id': data.get('stack_id', 'unknown') if 'data' in locals() else 'unknown' }), 504 except Exception as e: current_app.logger.error(f"Error updating stack: {str(e)}") return jsonify({'error': str(e)}), 500