diff --git a/routes/launch_api.py b/routes/launch_api.py index 85ecb38..1bec421 100644 --- a/routes/launch_api.py +++ b/routes/launch_api.py @@ -1870,7 +1870,7 @@ def copy_smtp_settings(): def update_stack(): try: data = request.get_json() - if not data or 'stack_id' not in data or 'StackFileContent' not in data: + if not data or 'stack_id' not in data: return jsonify({'error': 'Missing required fields'}), 400 # Get Portainer settings @@ -1924,7 +1924,7 @@ def update_stack(): 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 + # First, verify the stack exists and get its current configuration stack_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{data['stack_id']}" stack_response = requests.get( stack_url, @@ -1942,15 +1942,83 @@ def update_stack(): stack_info = stack_response.json() current_app.logger.info(f"Found existing stack: {stack_info['Name']} (ID: {stack_info['Id']})") + # Get the current stack file content from Portainer + stack_file_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{data['stack_id']}/file" + stack_file_response = requests.get( + stack_file_url, + headers={ + 'X-API-Key': portainer_settings['api_key'], + 'Accept': 'application/json' + }, + params={'endpointId': endpoint_id}, + timeout=30 + ) + + if not stack_file_response.ok: + current_app.logger.error(f"Failed to get stack file: {stack_file_response.text}") + return jsonify({'error': f'Failed to get existing stack file: {stack_file_response.text}'}), 500 + + stack_file_data = stack_file_response.json() + current_stack_file_content = stack_file_data.get('StackFileContent') + + if not current_stack_file_content: + current_app.logger.error("No StackFileContent found in existing stack") + return jsonify({'error': 'No existing stack file content found'}), 500 + + current_app.logger.info("Retrieved existing stack file content") + + # Get existing environment variables from the stack + existing_env_vars = stack_file_data.get('Env', []) + current_app.logger.info(f"Retrieved {len(existing_env_vars)} existing environment variables") + + # Create a dictionary of existing environment variables for easy lookup + existing_env_dict = {env['name']: env['value'] for env in existing_env_vars} + current_app.logger.info(f"Existing environment variables: {list(existing_env_dict.keys())}") + + # Get new environment variables from the request + new_env_vars = data.get('Env', []) + current_app.logger.info(f"New environment variables to update: {[env['name'] for env in new_env_vars]}") + + # Merge existing and new environment variables + # Start with existing variables + merged_env_vars = existing_env_vars.copy() + + # Update with new variables (this will overwrite existing ones with the same name) + for new_env in new_env_vars: + # Find if this environment variable already exists + existing_index = None + for i, existing_env in enumerate(merged_env_vars): + if existing_env['name'] == new_env['name']: + existing_index = i + break + + if existing_index is not None: + # Update existing variable + merged_env_vars[existing_index]['value'] = new_env['value'] + current_app.logger.info(f"Updated environment variable: {new_env['name']} = {new_env['value']}") + else: + # Add new variable + merged_env_vars.append(new_env) + current_app.logger.info(f"Added new environment variable: {new_env['name']} = {new_env['value']}") + + current_app.logger.info(f"Final merged environment variables: {[env['name'] for env in merged_env_vars]}") + # Update the stack using Portainer's update API - update_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{data['stack_id']}/update" + update_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{data['stack_id']}" 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', []) + 'StackFileContent': current_stack_file_content, # Use existing stack file content + 'Env': merged_env_vars # Use merged environment variables } + + # If new StackFileContent is provided, use it instead + if 'StackFileContent' in data: + request_body['StackFileContent'] = data['StackFileContent'] + current_app.logger.info("Using provided StackFileContent for update") + else: + current_app.logger.info("Using existing StackFileContent for update") # Add endpointId as a query parameter params = {'endpointId': endpoint_id} diff --git a/static/js/launch_progress.js b/static/js/launch_progress.js index 6a66c62..26fe373 100644 --- a/static/js/launch_progress.js +++ b/static/js/launch_progress.js @@ -1409,7 +1409,7 @@ async function startUpdate(data) { } // Update the existing stack instead of creating a new one - const stackResult = await updateStack(dockerComposeResult.content, instanceData.instance.portainer_stack_id, port); + const stackResult = await updateStack(dockerComposeResult.content, instanceData.instance.portainer_stack_id, port, instanceData.instance); if (!stackResult.success) { throw new Error(`Failed to update stack: ${stackResult.error}`); } @@ -2446,70 +2446,95 @@ async function checkInstanceHealth(instanceUrl) { const data = await statusResponse.json(); - // Update the health check step - const healthStepElement = document.querySelectorAll('.step-item')[10]; // Adjust index based on your steps - healthStepElement.classList.remove('active'); - healthStepElement.classList.add('completed'); - const statusText = healthStepElement.querySelector('.step-status'); + // Update the health check step - check if we're in update mode or launch mode + const isUpdate = window.isUpdate; + const healthStepIndex = isUpdate ? 4 : 10; // Different indices for update vs launch + const healthStepElement = document.querySelectorAll('.step-item')[healthStepIndex]; - if (data.status === 'active') { - const elapsedTime = Math.round((Date.now() - startTime) / 1000); - statusText.textContent = `Instance is healthy (Attempt ${currentAttempt}/${maxRetries}, ${elapsedTime}s elapsed)`; + if (healthStepElement) { + healthStepElement.classList.remove('active'); + healthStepElement.classList.add('completed'); + const statusText = healthStepElement.querySelector('.step-status'); - // Update progress bar to 100% - const progressBar = document.getElementById('healthProgress'); - const progressText = document.getElementById('healthProgressText'); - if (progressBar && progressText) { - progressBar.style.width = '100%'; - progressBar.textContent = '100%'; - progressBar.classList.remove('progress-bar-animated'); - progressBar.classList.add('bg-success'); - progressText.textContent = `Health check completed successfully in ${elapsedTime}s`; - } - - return { - success: true, - data: data, - attempts: currentAttempt, - elapsedTime: elapsedTime - }; - } else if (data.status === 'inactive') { - console.log(`Stack ${stackName} is inactive, continuing to poll...`); - lastKnownStatus = 'inactive'; - if (progressText) { - progressText.textContent = `Stack is starting up (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`; - } - } else if (data.status === 'starting') { - console.log(`Stack ${stackName} is starting up, continuing to poll...`); - lastKnownStatus = 'starting'; - if (progressText) { - progressText.textContent = `Stack is initializing (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`; + if (data.status === 'active') { + const elapsedTime = Math.round((Date.now() - startTime) / 1000); + statusText.textContent = `Instance is healthy (Attempt ${currentAttempt}/${maxRetries}, ${elapsedTime}s elapsed)`; + + // Update progress bar to 100% if it exists + const progressBar = document.getElementById('healthProgress'); + const progressText = document.getElementById('healthProgressText'); + if (progressBar && progressText) { + progressBar.style.width = '100%'; + progressBar.textContent = '100%'; + progressBar.classList.remove('progress-bar-animated'); + progressBar.classList.add('bg-success'); + progressText.textContent = `Health check completed successfully in ${elapsedTime}s`; + } + + return { + success: true, + data: data, + attempts: currentAttempt, + elapsedTime: elapsedTime + }; + } else if (data.status === 'inactive') { + console.log(`Stack ${stackName} is inactive, continuing to poll...`); + lastKnownStatus = 'inactive'; + if (progressText) { + progressText.textContent = `Stack is starting up (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`; + } + } else if (data.status === 'starting') { + console.log(`Stack ${stackName} is starting up, continuing to poll...`); + lastKnownStatus = 'starting'; + if (progressText) { + progressText.textContent = `Stack is initializing (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`; + } + } else { + throw new Error('Instance is not healthy'); } } else { - throw new Error('Instance is not healthy'); + // If step element doesn't exist, just log the status + console.log(`Health check - Instance status: ${data.status}`); + if (data.status === 'active') { + const elapsedTime = Math.round((Date.now() - startTime) / 1000); + return { + success: true, + data: data, + attempts: currentAttempt, + elapsedTime: elapsedTime + }; + } } } catch (error) { console.error(`Health check attempt ${currentAttempt} failed:`, error); - // Update status to show current attempt and elapsed time - const healthStepElement = document.querySelectorAll('.step-item')[10]; - const statusText = healthStepElement.querySelector('.step-status'); - const elapsedTime = Math.round((Date.now() - startTime) / 1000); - statusText.textContent = `Health check failed (Attempt ${currentAttempt}/${maxRetries}, ${elapsedTime}s elapsed): ${error.message}`; + // Update status to show current attempt and elapsed time - check if step element exists + const isUpdate = window.isUpdate; + const healthStepIndex = isUpdate ? 4 : 10; + const healthStepElement = document.querySelectorAll('.step-item')[healthStepIndex]; - // Update progress bar - const progressBar = document.getElementById('healthProgress'); - const progressText = document.getElementById('healthProgressText'); - if (progressBar && progressText) { - const progressPercent = Math.min((currentAttempt / maxRetries) * 100, 100); - progressBar.style.width = `${progressPercent}%`; - progressBar.textContent = `${Math.round(progressPercent)}%`; - progressText.textContent = `Attempt ${currentAttempt}/${maxRetries} (${elapsedTime}s elapsed)`; + if (healthStepElement) { + const statusText = healthStepElement.querySelector('.step-status'); + const elapsedTime = Math.round((Date.now() - startTime) / 1000); + statusText.textContent = `Health check failed (Attempt ${currentAttempt}/${maxRetries}, ${elapsedTime}s elapsed): ${error.message}`; + + // Update progress bar + const progressBar = document.getElementById('healthProgress'); + const progressText = document.getElementById('healthProgressText'); + if (progressBar && progressText) { + const progressPercent = Math.min((currentAttempt / maxRetries) * 100, 100); + progressBar.style.width = `${progressPercent}%`; + progressBar.textContent = `${Math.round(progressPercent)}%`; + progressText.textContent = `Attempt ${currentAttempt}/${maxRetries} (${elapsedTime}s elapsed)`; + } } if (currentAttempt === maxRetries || (Date.now() - startTime > maxTotalTime)) { - // Update progress bar to show failure + // Update progress bar to show failure if it exists + const progressBar = document.getElementById('healthProgress'); + const progressText = document.getElementById('healthProgressText'); if (progressBar && progressText) { + const elapsedTime = Math.round((Date.now() - startTime) / 1000); progressBar.classList.remove('progress-bar-animated'); progressBar.classList.add('bg-danger'); progressText.textContent = `Health check failed after ${currentAttempt} attempts (${elapsedTime}s)`; @@ -2517,7 +2542,7 @@ async function checkInstanceHealth(instanceUrl) { return { success: false, - error: `Health check failed after ${currentAttempt} attempts (${elapsedTime}s): ${error.message}` + error: `Health check failed after ${currentAttempt} attempts (${Math.round((Date.now() - startTime) / 1000)}s): ${error.message}` }; } @@ -2525,7 +2550,8 @@ async function checkInstanceHealth(instanceUrl) { await new Promise(resolve => setTimeout(resolve, baseDelay)); currentAttempt++; - // Update progress bar in real-time + // Update progress bar in real-time if it exists + const elapsedTime = Math.round((Date.now() - startTime) / 1000); updateHealthProgress(currentAttempt, maxRetries, elapsedTime); } } @@ -2543,127 +2569,6 @@ function updateHealthProgress(currentAttempt, maxRetries, elapsedTime) { } } -async function authenticateInstance(instanceUrl, instanceId) { - try { - // First check if instance is already authenticated - const instancesResponse = await fetch('/instances'); - const text = await instancesResponse.text(); - const parser = new DOMParser(); - const doc = parser.parseFromString(text, 'text/html'); - - // Find the instance with matching URL - const instanceRow = Array.from(doc.querySelectorAll('table tbody tr')).find(row => { - const urlCell = row.querySelector('td:nth-child(7) a'); // URL is in the 7th column - return urlCell && urlCell.textContent.trim() === instanceUrl; - }); - - if (!instanceRow) { - throw new Error('Instance not found in database'); - } - - // Get the instance ID from the status badge's data attribute - const statusBadge = instanceRow.querySelector('[data-instance-id]'); - if (!statusBadge) { - throw new Error('Could not find instance ID'); - } - - const dbInstanceId = statusBadge.dataset.instanceId; - - // Check if already authenticated - const authStatusResponse = await fetch(`/instances/${dbInstanceId}/auth-status`); - if (!authStatusResponse.ok) { - throw new Error('Failed to check authentication status'); - } - - const authStatus = await authStatusResponse.json(); - if (authStatus.authenticated) { - console.log('Instance is already authenticated'); - return { - success: true, - message: 'Instance is already authenticated', - alreadyAuthenticated: true - }; - } - - console.log('Attempting login to:', `${instanceUrl}/api/admin/login`); - - // First login to get token - const loginResponse = await fetch(`${instanceUrl}/api/admin/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }, - body: JSON.stringify({ - email: 'administrator@docupulse.com', - password: 'changeme' - }) - }); - - if (!loginResponse.ok) { - const errorText = await loginResponse.text(); - throw new Error(`Login failed: ${errorText}`); - } - - const loginData = await loginResponse.json(); - if (loginData.status !== 'success' || !loginData.token) { - throw new Error('Login failed: Invalid response from server'); - } - - const token = loginData.token; - - // Then create management API key - const keyResponse = await fetch(`${instanceUrl}/api/admin/management-api-key`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify({ - name: `Connection from ${window.location.hostname}` - }) - }); - - if (!keyResponse.ok) { - const errorText = await keyResponse.text(); - throw new Error(`Failed to create API key: ${errorText}`); - } - - const keyData = await keyResponse.json(); - if (!keyData.api_key) { - throw new Error('No API key received from server'); - } - - // Save the token to our database - const saveResponse = await fetch(`/instances/${dbInstanceId}/save-token`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content - }, - body: JSON.stringify({ token: keyData.api_key }) - }); - - if (!saveResponse.ok) { - const errorText = await saveResponse.text(); - throw new Error(`Failed to save token: ${errorText}`); - } - - return { - success: true, - message: 'Successfully authenticated instance', - alreadyAuthenticated: false - }; - } catch (error) { - console.error('Authentication error:', error); - return { - success: false, - error: error.message - }; - } -} - async function applyCompanyInformation(instanceUrl, company) { try { console.log('Applying company information to:', instanceUrl); @@ -3303,12 +3208,37 @@ function generateStackName(port) { } // Add new function to update existing stack -async function updateStack(dockerComposeContent, stackId, port) { +async function updateStack(dockerComposeContent, stackId, port, instanceData = null) { try { console.log('Updating existing stack:', stackId); console.log('Port:', port); console.log('Modified docker-compose content length:', dockerComposeContent.length); + // For updates, we only need to update version-related environment variables + // All other environment variables (pricing tiers, quotas, etc.) should be preserved + // We also preserve the existing docker-compose configuration + const envVars = [ + { + name: 'APP_VERSION', + value: window.currentDeploymentVersion || 'unknown' + }, + { + name: 'GIT_COMMIT', + value: window.currentDeploymentCommit || 'unknown' + }, + { + name: 'GIT_BRANCH', + value: window.currentDeploymentBranch || 'unknown' + }, + { + name: 'DEPLOYED_AT', + value: new Date().toISOString() + } + ]; + + console.log('Updating stack with version environment variables:', envVars); + console.log('Preserving existing docker-compose configuration'); + const response = await fetch('/api/admin/update-stack', { method: 'POST', headers: { @@ -3317,33 +3247,8 @@ async function updateStack(dockerComposeContent, stackId, port) { }, body: JSON.stringify({ stack_id: stackId, - StackFileContent: dockerComposeContent, - Env: [ - { - name: 'PORT', - value: port.toString() - }, - { - name: 'ISMASTER', - value: 'false' - }, - { - name: 'APP_VERSION', - value: window.currentDeploymentVersion || 'unknown' - }, - { - name: 'GIT_COMMIT', - value: window.currentDeploymentCommit || 'unknown' - }, - { - name: 'GIT_BRANCH', - value: window.currentDeploymentBranch || 'unknown' - }, - { - name: 'DEPLOYED_AT', - value: new Date().toISOString() - } - ] + // Don't send StackFileContent during updates - preserve existing configuration + Env: envVars }) }); @@ -3454,6 +3359,127 @@ async function updateStack(dockerComposeContent, stackId, port) { return pollResult; } + return { + success: false, + error: error.message + }; + } +} + +async function authenticateInstance(instanceUrl, instanceId) { + try { + // First check if instance is already authenticated + const instancesResponse = await fetch('/instances'); + const text = await instancesResponse.text(); + const parser = new DOMParser(); + const doc = parser.parseFromString(text, 'text/html'); + + // Find the instance with matching URL + const instanceRow = Array.from(doc.querySelectorAll('table tbody tr')).find(row => { + const urlCell = row.querySelector('td:nth-child(7) a'); // URL is in the 7th column + return urlCell && urlCell.textContent.trim() === instanceUrl; + }); + + if (!instanceRow) { + throw new Error('Instance not found in database'); + } + + // Get the instance ID from the status badge's data attribute + const statusBadge = instanceRow.querySelector('[data-instance-id]'); + if (!statusBadge) { + throw new Error('Could not find instance ID'); + } + + const dbInstanceId = statusBadge.dataset.instanceId; + + // Check if already authenticated + const authStatusResponse = await fetch(`/instances/${dbInstanceId}/auth-status`); + if (!authStatusResponse.ok) { + throw new Error('Failed to check authentication status'); + } + + const authStatus = await authStatusResponse.json(); + if (authStatus.authenticated) { + console.log('Instance is already authenticated'); + return { + success: true, + message: 'Instance is already authenticated', + alreadyAuthenticated: true + }; + } + + console.log('Attempting login to:', `${instanceUrl}/api/admin/login`); + + // First login to get token + const loginResponse = await fetch(`${instanceUrl}/api/admin/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + email: 'administrator@docupulse.com', + password: 'changeme' + }) + }); + + if (!loginResponse.ok) { + const errorText = await loginResponse.text(); + throw new Error(`Login failed: ${errorText}`); + } + + const loginData = await loginResponse.json(); + if (loginData.status !== 'success' || !loginData.token) { + throw new Error('Login failed: Invalid response from server'); + } + + const token = loginData.token; + + // Then create management API key + const keyResponse = await fetch(`${instanceUrl}/api/admin/management-api-key`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + name: `Connection from ${window.location.hostname}` + }) + }); + + if (!keyResponse.ok) { + const errorText = await keyResponse.text(); + throw new Error(`Failed to create API key: ${errorText}`); + } + + const keyData = await keyResponse.json(); + if (!keyData.api_key) { + throw new Error('No API key received from server'); + } + + // Save the token to our database + const saveResponse = await fetch(`/instances/${dbInstanceId}/save-token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content + }, + body: JSON.stringify({ token: keyData.api_key }) + }); + + if (!saveResponse.ok) { + const errorText = await saveResponse.text(); + throw new Error(`Failed to save token: ${errorText}`); + } + + return { + success: true, + message: 'Successfully authenticated instance', + alreadyAuthenticated: false + }; + } catch (error) { + console.error('Authentication error:', error); return { success: false, error: error.message