diff --git a/routes/launch_api.py b/routes/launch_api.py index 5fc3cb8..85ecb38 100644 --- a/routes/launch_api.py +++ b/routes/launch_api.py @@ -929,8 +929,8 @@ def deploy_stack(): def check_stack_status(): try: data = request.get_json() - if not data or 'stack_name' not in data: - return jsonify({'error': 'Missing stack_name field'}), 400 + 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') @@ -956,35 +956,54 @@ def check_stack_status(): endpoint_id = endpoints[0]['Id'] - # Get stack information - 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 - ) + # 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 stacks_response.ok: - return jsonify({'error': 'Failed to get stack information'}), 500 + if not stack_response.ok: + return jsonify({'error': f'Stack with ID {data["stack_id"]} not found'}), 404 - stacks = stacks_response.json() - target_stack = None - - for stack in stacks: - if stack['Name'] == data['stack_name']: - target_stack = stack - break + 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 target_stack: - return jsonify({'error': f'Stack {data["stack_name"]} not found'}), 404 + 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 {data['stack_name']} at endpoint {endpoint_id}") + current_app.logger.info(f"Checking services for stack {target_stack['Name']} at endpoint {endpoint_id}") try: services_response = requests.get( @@ -993,7 +1012,7 @@ def check_stack_status(): 'X-API-Key': portainer_settings['api_key'], 'Accept': 'application/json' }, - params={'filters': json.dumps({'label': f'com.docker.stack.namespace={data["stack_name"]}'})}, + params={'filters': json.dumps({'label': f'com.docker.stack.namespace={target_stack["Name"]}'})}, timeout=30 ) @@ -1001,46 +1020,40 @@ def check_stack_status(): if services_response.ok: services = services_response.json() - current_app.logger.info(f"Found {len(services)} services for stack {data['stack_name']}") - - # Check if all services are running - all_running = True service_statuses = [] for service in services: - replicas_running = service.get('Spec', {}).get('Mode', {}).get('Replicated', {}).get('Replicas', 0) - replicas_actual = service.get('ServiceStatus', {}).get('RunningTasks', 0) - - service_status = { + service_statuses.append({ 'name': service.get('Spec', {}).get('Name', 'Unknown'), - 'replicas_expected': replicas_running, - 'replicas_running': replicas_actual, - 'status': 'running' if replicas_actual >= replicas_running else 'not_running' - } - - service_statuses.append(service_status) - - if replicas_actual < replicas_running: - all_running = False - + '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 all_running and len(services) > 0: - status = 'active' - elif len(services) > 0: - status = 'partial' + if not service_statuses: + status = 'starting' # No services found yet else: - status = 'inactive' + 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 {data['stack_name']}: {services_response.status_code} - {services_response.text}") + 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 {data['stack_name']} - stack may still be initializing") + 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 {data['stack_name']} - check Portainer permissions") + 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 {data['stack_name']}") + current_app.logger.warning(f"Portainer server error when getting services for stack {target_stack['Name']}") services = [] service_statuses = [] @@ -1048,7 +1061,7 @@ def check_stack_status(): except Exception as e: # Exception occurred while getting services, but stack exists - current_app.logger.warning(f"Exception getting services for stack {data['stack_name']}: {str(e)}") + 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 @@ -1056,14 +1069,10 @@ def check_stack_status(): return jsonify({ 'success': True, 'data': { - 'stack_name': data['stack_name'], + 'name': target_stack['Name'], 'stack_id': target_stack['Id'], 'status': status, - 'services': service_statuses, - 'total_services': len(services), - 'running_services': len([s for s in service_statuses if s['status'] == 'running']), - 'stack_created_at': target_stack.get('CreatedAt', 'unknown'), - 'stack_updated_at': target_stack.get('UpdatedAt', 'unknown') + 'services': service_statuses } }) @@ -1854,4 +1863,148 @@ def copy_smtp_settings(): 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 \ No newline at end of file diff --git a/static/js/launch_progress.js b/static/js/launch_progress.js index 0896045..6a66c62 100644 --- a/static/js/launch_progress.js +++ b/static/js/launch_progress.js @@ -1395,7 +1395,7 @@ async function startUpdate(data) { // Step 3: Deploy Updated Stack await updateStep(3, 'Deploying Updated Stack', 'Deploying the updated application stack...'); - // Get the existing instance information to extract port + // Get the existing instance information to extract port and stack details const instanceResponse = await fetch(`/api/instances/${data.instanceId}`); if (!instanceResponse.ok) { throw new Error('Failed to get instance information'); @@ -1403,15 +1403,18 @@ async function startUpdate(data) { const instanceData = await instanceResponse.json(); const port = instanceData.instance.name; // Assuming the instance name is the port - // Generate new stack name with timestamp - const newStackName = generateStackName(port); + // Check if we have an existing stack ID + if (!instanceData.instance.portainer_stack_id) { + throw new Error('No existing stack found for this instance. Cannot perform update.'); + } - const stackResult = await deployStack(dockerComposeResult.content, newStackName, port); + // Update the existing stack instead of creating a new one + const stackResult = await updateStack(dockerComposeResult.content, instanceData.instance.portainer_stack_id, port); if (!stackResult.success) { - throw new Error(`Failed to deploy updated stack: ${stackResult.error}`); + throw new Error(`Failed to update stack: ${stackResult.error}`); } launchReport.steps.push({ - step: 'Stack Deployment', + step: 'Stack Update', status: 'success', details: stackResult }); @@ -1422,8 +1425,8 @@ async function startUpdate(data) { name: instanceData.instance.name, port: port, domains: instanceData.instance.main_url ? [instanceData.instance.main_url.replace(/^https?:\/\//, '')] : [], - stack_id: stackResult.data.id || null, - stack_name: newStackName, + stack_id: instanceData.instance.portainer_stack_id, // Keep the same stack ID + stack_name: instanceData.instance.portainer_stack_name, // Keep the same stack name status: stackResult.data.status, repository: data.repository, branch: data.branch, @@ -1468,7 +1471,7 @@ async function startUpdate(data) { successDetails.className = 'mt-3'; // Calculate the volume names based on the stack name - const stackNameParts = newStackName.split('_'); + const stackNameParts = stackResult.data.name.split('_'); let volumeNames = []; if (stackNameParts.length >= 3) { const timestamp = stackNameParts.slice(2).join('_'); @@ -1482,7 +1485,7 @@ async function startUpdate(data) { successDetails.innerHTML = `
Your instance has been updated with the latest version from the repository.
+Your instance has been updated with the latest version from the repository. All existing data and volumes have been preserved.
${name}`).join('