diff --git a/README.md b/README.md index 96fd3cdd4..64628c598 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ **Web Hosting Control Panel powered by OpenLiteSpeed** Fast • Secure • Scalable — Simplify hosting management with style. -**Version**: 2.5.5-dev • **Updated**: January 15, 2026 +**Version**: 2.5.5-dev • **Updated**: 28.03.2026 [![GitHub](https://img.shields.io/badge/GitHub-Repo-000?style=flat-square\&logo=github)](https://github.com/usmannasir/cyberpanel) [![Docs](https://img.shields.io/badge/Docs-Read-green?style=flat-square\&logo=gitbook)](https://cyberpanel.net/KnowledgeBase/) diff --git a/baseTemplate/templates/baseTemplate/index.html b/baseTemplate/templates/baseTemplate/index.html index a090cfd21..0ea106fb6 100644 --- a/baseTemplate/templates/baseTemplate/index.html +++ b/baseTemplate/templates/baseTemplate/index.html @@ -410,6 +410,63 @@ text-decoration: underline; color: #3730a3 !important; } + + /* Ephemeral notifications (e.g. Docker update progress) */ + .notification-center-item-ephemeral { + border-color: #c7d2fe; + background: linear-gradient(135deg, #f8fafc 0%, #eef2ff 100%); + } + .notification-center-progress-track { + height: 8px; + border-radius: 999px; + background: #e5e7eb; + overflow: hidden; + margin-top: 0.5rem; + } + .notification-center-progress-bar { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, #4f46e5, #6366f1); + transition: width 0.35s ease; + } + .notification-center-progress-bar.indeterminate { + width: 35% !important; + animation: cp-nc-progress-indet 1.2s ease-in-out infinite; + } + .notification-center-progress-bar.success { + width: 100% !important; + background: linear-gradient(90deg, #16a34a, #22c55e); + animation: none; + } + .notification-center-progress-bar.error { + width: 100% !important; + background: linear-gradient(90deg, #dc2626, #ef4444); + animation: none; + } + @keyframes cp-nc-progress-indet { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(280%); } + } + .notification-center-ephemeral-dismiss { + margin-top: 0.75rem; + font-size: 0.8rem; + color: #6b7280; + background: none; + border: none; + cursor: pointer; + padding: 0; + text-decoration: underline; + } + .notification-center-ephemeral-dismiss:hover { color: #111827; } + .notification-center-btn.has-active-operation { + animation: cp-nc-bell-pulse 1.5s ease-in-out infinite; + border-color: #6366f1; + color: #4f46e5; + } + @keyframes cp-nc-bell-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(79, 70, 229, 0.35); } + 50% { box-shadow: 0 0 0 6px rgba(79, 70, 229, 0); } + } /* Sidebar */ #sidebar { @@ -2329,9 +2386,6 @@ AI Scanner - - Security Management - @@ -2507,7 +2561,7 @@ - + @@ -2753,6 +2807,93 @@ } return false; } + + window.__cpEphemeralNotifications = window.__cpEphemeralNotifications || []; + + function cpEscapeHtmlNC(str) { + if (str == null || str === '') return ''; + var div = document.createElement('div'); + div.textContent = String(str); + return div.innerHTML; + } + + function cpEscapeAttrNC(s) { + return String(s || '') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + } + + function renderEphemeralNotificationsHTML() { + var items = window.__cpEphemeralNotifications || []; + return items.map(function(notif) { + var nameEsc = cpEscapeHtmlNC(notif.containerName); + var imgEsc = cpEscapeHtmlNC(notif.imageRef); + var idAttr = cpEscapeAttrNC(notif.id); + var textHtml = ''; + var barHtml = ''; + if (notif.state === 'running') { + textHtml = '

Updating ' + nameEsc + ' to ' + imgEsc + '. Pulling image and recreating the container — you can leave this tab open.

'; + barHtml = '
'; + } else if (notif.state === 'done_ok') { + textHtml = '

' + nameEsc + ' updated successfully.

' + cpEscapeHtmlNC(notif.resultMessage) + '

'; + barHtml = '
'; + } else { + textHtml = '

Update failed for ' + nameEsc + '.

' + cpEscapeHtmlNC(notif.resultMessage) + '

'; + barHtml = '
'; + } + return '
' + + '
Docker update
' + + '
' + textHtml + '
' + + barHtml + + '' + + '
'; + }).join(''); + } + + window.cpDismissEphemeralNotification = function(id) { + window.__cpEphemeralNotifications = (window.__cpEphemeralNotifications || []).filter(function(n) { return n.id !== id; }); + var stillRunning = (window.__cpEphemeralNotifications || []).some(function(n) { return n.state === 'running'; }); + var btn = document.getElementById('notification-center-btn'); + if (btn && !stillRunning) btn.classList.remove('has-active-operation'); + loadNotificationCenter(); + }; + + window.cpDockerUpdateNotifyStart = function(containerName, imageRef) { + window.__cpEphemeralNotifications = window.__cpEphemeralNotifications || []; + var id = 'docker-update-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9); + window.__cpEphemeralNotifications.unshift({ + id: id, + kind: 'docker-update', + containerName: String(containerName || ''), + imageRef: String(imageRef || ''), + state: 'running', + resultMessage: '' + }); + var dd = document.getElementById('notification-center-dropdown'); + if (dd) dd.classList.add('show'); + var bell = document.getElementById('notification-center-btn'); + if (bell) bell.classList.add('has-active-operation'); + loadNotificationCenter(); + return id; + }; + + window.cpDockerUpdateNotifyEnd = function(nid, ok, message) { + window.__cpEphemeralNotifications = window.__cpEphemeralNotifications || []; + var n = null; + for (var i = 0; i < window.__cpEphemeralNotifications.length; i++) { + if (window.__cpEphemeralNotifications[i].id === nid) { n = window.__cpEphemeralNotifications[i]; break; } + } + if (!n) return; + n.state = ok ? 'done_ok' : 'done_err'; + n.resultMessage = message ? String(message) : (ok ? 'Done.' : 'Unknown error.'); + var stillRunning = window.__cpEphemeralNotifications.some(function(x) { return x.state === 'running'; }); + var btn = document.getElementById('notification-center-btn'); + if (btn && !stillRunning) btn.classList.remove('has-active-operation'); + loadNotificationCenter(); + }; + function toggleNotificationCenter() { const dropdown = document.getElementById('notification-center-dropdown'); if (dropdown) { @@ -2782,10 +2923,10 @@ learnMoreLink: 'https://cyberpanel.net/cyberpanel-htaccess-module', dismissed: isNotificationDismissed('htaccess-notification') } ]; - if (notifications.length === 0) { - list.innerHTML = '
No notifications available
'; - } else { - list.innerHTML = notifications.map(notif => { + const ephemeralHtml = renderEphemeralNotificationsHTML(); + let staticHtml = ''; + if (notifications.length > 0) { + staticHtml = notifications.map(notif => { let linkIcon = notif.linkText.includes('Configure') ? '' : notif.linkText.includes('Start') ? '' : (notif.linkText.includes('View') || notif.linkText.includes('Details')) ? '' : ''; @@ -2805,7 +2946,14 @@ `; }).join(''); } - const activeCount = notifications.filter(n => !n.dismissed).length; + if (!ephemeralHtml && !staticHtml) { + list.innerHTML = '
No notifications available
'; + } else { + list.innerHTML = ephemeralHtml + staticHtml; + } + const staticActive = notifications.filter(n => !n.dismissed).length; + const runningEphem = (window.__cpEphemeralNotifications || []).filter(function(n) { return n.state === 'running'; }).length; + const activeCount = staticActive + runningEphem; const badge = document.getElementById('notification-badge'); if (badge) { badge.textContent = activeCount; @@ -2822,6 +2970,16 @@ // Check all notification statuses when page loads document.addEventListener('DOMContentLoaded', function() { + var ncList = document.getElementById('notification-center-list'); + if (ncList) { + ncList.addEventListener('click', function(ev) { + var dismissBtn = ev.target.closest('[data-cp-ephemeral-dismiss]'); + if (dismissBtn && window.cpDismissEphemeralNotification) { + var eid = dismissBtn.getAttribute('data-cp-ephemeral-dismiss'); + if (eid) window.cpDismissEphemeralNotification(eid); + } + }); + } loadNotificationCenter(); checkBackupStatus(); // Optional: open notification dropdown for testing (e.g. ?showNotifications=1) @@ -2953,9 +3111,6 @@ }); } - function loadSecurityManagement() { - window.open('{% url "securityManagementPage" %}', '_blank'); - } {% block footer_scripts %}{% endblock %} diff --git a/dockerManager/container.py b/dockerManager/container.py index efdbc3635..c3f0f95a0 100644 --- a/dockerManager/container.py +++ b/dockerManager/container.py @@ -772,9 +772,28 @@ class ContainerManager(multi.Thread): end = start + items_per_page page_containers = all_containers[start:end] + client = docker.from_env() rows = [] for items in page_containers: - rows.append({'name': items.name, 'admin': items.admin.userName, 'tag': items.tag, 'image': items.image}) + disp_image = items.image + disp_tag = items.tag + try: + running = client.containers.get(items.name) + cfg_ref = running.attrs.get('Config', {}).get('Image') or '' + if cfg_ref and '@' not in cfg_ref: + if ':' in cfg_ref: + disp_image, disp_tag = cfg_ref.rsplit(':', 1) + else: + disp_image, disp_tag = cfg_ref, 'latest' + elif running.image and running.image.tags: + ref = running.image.tags[0] + if ':' in ref: + disp_image, disp_tag = ref.rsplit(':', 1) + else: + disp_image, disp_tag = ref, 'latest' + except Exception: + pass + rows.append({'name': items.name, 'admin': items.admin.userName, 'tag': disp_tag, 'image': disp_image}) json_data = json.dumps(rows) final_dic = { @@ -2346,15 +2365,28 @@ class ContainerManager(multi.Thread): client = docker.from_env() dockerAPI = docker.APIClient() - containerName = data['containerName'] - newImage = data['newImage'] - newTag = data.get('newTag', 'latest') + # UI (dockerManager.js) sends "name"; older callers may use "containerName" + containerName = (data.get('containerName') or data.get('name') or '').strip() + if not containerName: + data_ret = {'updateContainerStatus': 0, 'error_message': 'Container name is required'} + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + + newImage = (data.get('newImage') or '').strip() + newTag = (data.get('newTag') or 'latest').strip() or 'latest' # Get the current container try: currentContainer = client.containers.get(containerName) except docker.errors.NotFound: - data_ret = {'updateContainerStatus': 0, 'error_message': f'Container {containerName} not found'} + data_ret = { + 'updateContainerStatus': 0, + 'error_message': ( + f'Container {containerName} not found. ' + 'If you clicked Update twice, wait for the first request to finish; ' + 'do not start another update until you see success or failure.' + ), + } json_data = json.dumps(data_ret) return HttpResponse(json_data) except Exception as e: @@ -2365,6 +2397,19 @@ class ContainerManager(multi.Thread): # Get container configuration for recreation containerConfig = currentContainer.attrs['Config'] hostConfig = currentContainer.attrs['HostConfig'] + + # If no new image specified, use current image repository (same as first updateContainer implementation) + if not newImage: + current_image = containerConfig.get('Image', '') or '' + if ':' in current_image: + newImage = current_image.split(':')[0] + else: + newImage = current_image + newTag = 'latest' + if not newImage: + data_ret = {'updateContainerStatus': 0, 'error_message': 'Could not determine image name for update'} + json_data = json.dumps(data_ret) + return HttpResponse(json_data) # Extract volumes for data preservation volumes = {} @@ -2399,6 +2444,20 @@ class ContainerManager(multi.Thread): if memory_limit > 0: memory_limit = memory_limit // 1048576 # Convert bytes to MB + image_name = f"{newImage}:{newTag}" + + # Pull BEFORE stop/remove so a slow/failed pull never leaves the container deleted + # (double-clicks or retries would otherwise see "container not found"). + try: + logging.CyberCPLogFileWriter.writeToFile(f'Pulling new image {image_name} (container still running)') + client.images.pull(newImage, tag=newTag) + logging.CyberCPLogFileWriter.writeToFile(f'Successfully pulled image {image_name}') + except Exception as e: + logging.CyberCPLogFileWriter.writeToFile(f'Error pulling image {newImage}:{newTag}: {str(e)}') + data_ret = {'updateContainerStatus': 0, 'error_message': f'Error pulling new image: {str(e)}'} + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + # Stop the current container try: if currentContainer.status == 'running': @@ -2420,18 +2479,6 @@ class ContainerManager(multi.Thread): json_data = json.dumps(data_ret) return HttpResponse(json_data) - # Pull the new image - try: - image_name = f"{newImage}:{newTag}" - logging.CyberCPLogFileWriter.writeToFile(f'Pulling new image {image_name}') - client.images.pull(newImage, tag=newTag) - logging.CyberCPLogFileWriter.writeToFile(f'Successfully pulled image {image_name}') - except Exception as e: - logging.CyberCPLogFileWriter.writeToFile(f'Error pulling image {newImage}:{newTag}: {str(e)}') - data_ret = {'updateContainerStatus': 0, 'error_message': f'Error pulling new image: {str(e)}'} - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - # Create new container with same configuration but new image try: containerArgs = { @@ -2475,12 +2522,27 @@ class ContainerManager(multi.Thread): json_data = json.dumps(data_ret) return HttpResponse(json_data) + # Sync DB — container list UI reads image/tag from Containers model, not Docker + try: + container_record = Containers.objects.get(name=containerName) + container_record.image = newImage + container_record.tag = newTag + container_record.cid = newContainer.short_id + container_record.save() + except Containers.DoesNotExist: + pass + except Exception as db_err: + logging.CyberCPLogFileWriter.writeToFile( + f'updateContainer DB sync failed for {containerName}: {db_err}' + ) + # Log successful update logging.CyberCPLogFileWriter.writeToFile(f'Successfully updated container {containerName} to image {image_name}') data_ret = { - 'updateContainerStatus': 1, + 'updateContainerStatus': 1, 'error_message': 'None', + 'new_image': image_name, 'message': f'Container {containerName} successfully updated to {image_name}' } json_data = json.dumps(data_ret) diff --git a/dockerManager/static/dockerManager/dockerManager.js b/dockerManager/static/dockerManager/dockerManager.js index ba9569bef..e89dcf7c0 100644 --- a/dockerManager/static/dockerManager/dockerManager.js +++ b/dockerManager/static/dockerManager/dockerManager.js @@ -974,6 +974,15 @@ app.controller('listContainers', function ($scope, $http) { return; } + if ($scope.dockerUpdateInProgress) { + new PNotify({ + title: 'Update in progress', + text: 'Wait until the current update finishes before starting another.', + type: 'warning' + }); + return; + } + // If no new image specified, use current image if (!$scope.newImage) { $scope.newImage = $scope.currentImage; @@ -1000,9 +1009,18 @@ app.controller('listContainers', function ($scope, $http) { history: false } })).get().on('pnotify.confirm', function () { + var dockerUpdateNotificationId = null; + $scope.dockerUpdateInProgress = true; $('#imageLoading').show(); $("#updateContainer").modal("hide"); + if (typeof window.cpDockerUpdateNotifyStart === 'function') { + dockerUpdateNotificationId = window.cpDockerUpdateNotifyStart( + $scope.updateContainerName, + $scope.newImage + ':' + $scope.newTag + ); + } + url = "/docker/updateContainer"; var data = { name: $scope.updateContainerName, @@ -1020,15 +1038,27 @@ app.controller('listContainers', function ($scope, $http) { function ListInitialData(response) { console.log(response); + $scope.dockerUpdateInProgress = false; $('#imageLoading').hide(); - if (response.data.updateContainerStatus === 1) { + var ok = response.data && response.data.updateContainerStatus === 1; + var imgLabel = ok + ? (response.data.new_image || response.data.message || 'Updated') + : (response.data && response.data.error_message ? response.data.error_message : 'Update failed'); + + if (typeof window.cpDockerUpdateNotifyEnd === 'function' && dockerUpdateNotificationId) { + window.cpDockerUpdateNotifyEnd(dockerUpdateNotificationId, ok, imgLabel); + } + + if (ok) { new PNotify({ title: 'Container Updated Successfully', - text: `Container updated to ${response.data.new_image}`, + text: 'Container updated to ' + (response.data.new_image || response.data.message || 'new image'), type: 'success' }); - location.reload(); + setTimeout(function () { + location.reload(); + }, 2200); } else { new PNotify({ title: 'Update Failed', @@ -1039,7 +1069,11 @@ app.controller('listContainers', function ($scope, $http) { } function cantLoadInitialData(response) { + $scope.dockerUpdateInProgress = false; $('#imageLoading').hide(); + if (typeof window.cpDockerUpdateNotifyEnd === 'function' && dockerUpdateNotificationId) { + window.cpDockerUpdateNotifyEnd(dockerUpdateNotificationId, false, 'Could not connect to server'); + } new PNotify({ title: 'Update Failed', text: 'Could not connect to server', diff --git a/install/install.py b/install/install.py index f66fc189b..897af0aea 100644 --- a/install/install.py +++ b/install/install.py @@ -6250,6 +6250,31 @@ vmail command = 'systemctl enable redis' preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR) + def installRabbitMQ(self): + rabbitMQMarker = '/home/cyberpanel/rabbitmq' + + # Keep optional installer idempotent for reruns/retries. + if os.path.exists(rabbitMQMarker): + preFlightsChecks.stdOut("RabbitMQ marker already exists, skipping optional RabbitMQ installation.") + return + + if self.distro == ubuntu or self.distro == debian12: + command = 'DEBIAN_FRONTEND=noninteractive apt install rabbitmq-server -y' + elif self.distro == centos: + command = 'yum install rabbitmq-server -y' + else: + command = 'dnf install rabbitmq-server -y' + preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR) + + command = 'systemctl enable rabbitmq-server' + preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR) + + command = 'systemctl start rabbitmq-server' + preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR) + + writeToFile = open(rabbitMQMarker, 'w') + writeToFile.close() + def disablePackegeUpdates(self): if self.distro == centos: mainConfFile = '/etc/yum.conf' @@ -6752,6 +6777,7 @@ def main(): parser.add_argument('--serial', help='Install LS Ent or OpenLiteSpeed') parser.add_argument('--port', help='LSCPD Port') parser.add_argument('--redis', help='vHosts on Redis - Requires LiteSpeed Enterprise') + parser.add_argument('--rabbitmq', help='Enable optional RabbitMQ installation.') parser.add_argument('--remotemysql', help='Opt to choose local or remote MySQL') parser.add_argument('--mysqlhost', help='MySQL host if remote is chosen.') parser.add_argument('--mysqldb', help='MySQL DB if remote is chosen.') @@ -7020,6 +7046,9 @@ def main(): if args.redis is not None: checks.installRedis() + if args.rabbitmq is not None and str(args.rabbitmq).upper() == 'ON': + checks.installRabbitMQ() + if args.powerdns is not None: checks.enableDisableDNS(args.powerdns.lower()) else: diff --git a/loginSystem/migrations/0001_initial.py b/loginSystem/migrations/0001_initial.py new file mode 100644 index 000000000..48c822515 --- /dev/null +++ b/loginSystem/migrations/0001_initial.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# Generated for CyberPanel: loginSystem had models but no migrations, which broke +# the global dependency graph (e.g. dockerManager depends on loginSystem.__first__). +# +# Pre-existing panels already have loginSystem_* tables from legacy installs. +# This migration updates Django state only; it does not run DDL. + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.CreateModel( + name='ACL', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('adminStatus', models.IntegerField(default=0)), + ('versionManagement', models.IntegerField(default=0)), + ('createNewUser', models.IntegerField(default=0)), + ('listUsers', models.IntegerField(default=0)), + ('deleteUser', models.IntegerField(default=0)), + ('resellerCenter', models.IntegerField(default=0)), + ('changeUserACL', models.IntegerField(default=0)), + ('createWebsite', models.IntegerField(default=0)), + ('modifyWebsite', models.IntegerField(default=0)), + ('suspendWebsite', models.IntegerField(default=0)), + ('deleteWebsite', models.IntegerField(default=0)), + ('createPackage', models.IntegerField(default=0)), + ('listPackages', models.IntegerField(default=0)), + ('deletePackage', models.IntegerField(default=0)), + ('modifyPackage', models.IntegerField(default=0)), + ('createDatabase', models.IntegerField(default=1)), + ('deleteDatabase', models.IntegerField(default=1)), + ('listDatabases', models.IntegerField(default=1)), + ('createNameServer', models.IntegerField(default=0)), + ('createDNSZone', models.IntegerField(default=1)), + ('deleteZone', models.IntegerField(default=1)), + ('addDeleteRecords', models.IntegerField(default=1)), + ('createEmail', models.IntegerField(default=1)), + ('listEmails', models.IntegerField(default=1)), + ('deleteEmail', models.IntegerField(default=1)), + ('emailForwarding', models.IntegerField(default=1)), + ('changeEmailPassword', models.IntegerField(default=1)), + ('dkimManager', models.IntegerField(default=1)), + ('createFTPAccount', models.IntegerField(default=1)), + ('deleteFTPAccount', models.IntegerField(default=1)), + ('listFTPAccounts', models.IntegerField(default=1)), + ('createBackup', models.IntegerField(default=0)), + ('restoreBackup', models.IntegerField(default=0)), + ('addDeleteDestinations', models.IntegerField(default=0)), + ('scheduleBackups', models.IntegerField(default=0)), + ('remoteBackups', models.IntegerField(default=0)), + ('manageSSL', models.IntegerField(default=1)), + ('hostnameSSL', models.IntegerField(default=0)), + ('mailServerSSL', models.IntegerField(default=0)), + ('config', models.TextField(default='{}')), + ], + ), + migrations.CreateModel( + name='Administrator', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('userName', models.CharField(max_length=50, unique=True)), + ('password', models.CharField(max_length=200)), + ('firstName', models.CharField(default='None', max_length=200)), + ('lastName', models.CharField(default='None', max_length=200)), + ('email', models.CharField(max_length=50)), + ('type', models.IntegerField()), + ('owner', models.IntegerField(default=1)), + ('token', models.CharField(default='None', max_length=500)), + ('api', models.IntegerField(default=0)), + ( + 'securityLevel', + models.IntegerField(choices=[(0, 'HIGH'), (1, 'LOW')], default=0), + ), + ('state', models.CharField(default='ACTIVE', max_length=10)), + ('initWebsitesLimit', models.IntegerField(default=0)), + ('twoFA', models.IntegerField(default=0)), + ('secretKey', models.CharField(default='None', max_length=50)), + ('config', models.TextField(default='{}')), + ('defaultSite', models.IntegerField(default=0)), + ( + 'acl', + models.ForeignKey( + default=1, + on_delete=django.db.models.deletion.PROTECT, + to='loginSystem.acl', + ), + ), + ], + ), + ], + ), + ] diff --git a/loginSystem/migrations/__init__.py b/loginSystem/migrations/__init__.py index e69de29bb..4422ab3a8 100644 --- a/loginSystem/migrations/__init__.py +++ b/loginSystem/migrations/__init__.py @@ -0,0 +1 @@ +# loginSystem migrations package (CyberPanel core) diff --git a/manageServices/application_backup.py b/manageServices/application_backup.py new file mode 100644 index 000000000..9f8fd0506 --- /dev/null +++ b/manageServices/application_backup.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- +""" +Full config + data backups for managed applications (pre version change). +""" +import json +import os +import shutil +import subprocess +import tarfile +import time + +CONFIG_PATHS = { + 'Elasticsearch': ['/etc/elasticsearch'], + 'Redis': ['/etc/redis', '/etc/redis.conf'], + 'RabbitMQ': ['/etc/rabbitmq'], +} + +DATA_PATHS = { + 'Elasticsearch': ['/var/lib/elasticsearch'], + 'Redis': ['/var/lib/redis'], + 'RabbitMQ': ['/var/lib/rabbitmq'], +} + +SERVICE_UNITS = { + 'Elasticsearch': 'elasticsearch', + 'Redis': 'redis', + 'RabbitMQ': 'rabbitmq-server', +} + +CHOWN_CMDS = { + 'Elasticsearch': 'chown -R elasticsearch:elasticsearch /var/lib/elasticsearch /etc/elasticsearch', + 'Redis': 'chown -R redis:redis /var/lib/redis /etc/redis /etc/redis.conf 2>/dev/null; true', + 'RabbitMQ': 'chown -R rabbitmq:rabbitmq /var/lib/rabbitmq /etc/rabbitmq', +} + +BACKUP_ROOT = '/home/cyberpanel/backups/manageApplications' + + +def _existing_paths(app_name): + out = [] + for p in CONFIG_PATHS.get(app_name, []) + DATA_PATHS.get(app_name, []): + if os.path.exists(p): + out.append(p) + return out + + +def create_managed_app_backup(app_name, status_file): + """ + Tar config + data paths into BACKUP_ROOT///bundle.tar.gz. + Returns backup directory path, or '' on failure / nothing to back up. + """ + def log(msg): + try: + status_file.write(msg + '\n') + status_file.flush() + except Exception: + pass + + paths = _existing_paths(app_name) + if not paths: + log('No paths on disk to back up for {0}; skipping archive.'.format(app_name)) + return '' + + ts = int(time.time()) + safe = app_name.lower().replace(' ', '_') + backup_dir = os.path.join(BACKUP_ROOT, safe, str(ts)) + os.makedirs(backup_dir, mode=0o750, exist_ok=True) + archive = os.path.join(backup_dir, 'bundle.tar.gz') + + try: + with tarfile.open(archive, 'w:gz', compresslevel=6) as tf: + for abs_path in paths: + arc = abs_path.lstrip('/') + tf.add(abs_path, arcname=arc, recursive=True) + manifest = { + 'app': app_name, + 'created': ts, + 'paths': [p.lstrip('/') for p in paths], + } + with open(os.path.join(backup_dir, 'manifest.json'), 'w') as mh: + json.dump(manifest, mh, indent=2) + log('Backup created at {0}'.format(backup_dir)) + return backup_dir + except Exception as err: + log('Backup failed: {0}'.format(err)) + try: + shutil.rmtree(backup_dir, ignore_errors=True) + except Exception: + pass + return '' + + +def _archive_path(backup_dir): + return os.path.join(backup_dir, 'bundle.tar.gz') + + +def merge_data_from_backup(app_name, backup_dir, status_file): + """Overlay saved data directories from backup onto live system (preserves package layout).""" + def log(msg): + try: + status_file.write(msg + '\n') + status_file.flush() + except Exception: + pass + + arc = _archive_path(backup_dir) + if not os.path.isfile(arc): + log('No bundle at {0}; skip data merge.'.format(arc)) + return False + data_prefixes = [p.lstrip('/') for p in DATA_PATHS.get(app_name, [])] + if not data_prefixes: + return True + try: + with tarfile.open(arc, 'r:gz') as tf: + for m in tf.getmembers(): + name = m.name + if m.isfile() or m.isdir(): + for pref in data_prefixes: + if name == pref or name.startswith(pref + '/'): + tf.extract(m, path='/', set_attrs=False) + break + log('Merged data trees from backup for {0}.'.format(app_name)) + return True + except Exception as err: + log('Data merge failed: {0}'.format(err)) + return False + + +def restore_full_backup(backup_dir, status_file): + """Extract full bundle to / (recovery).""" + def log(msg): + try: + status_file.write(msg + '\n') + status_file.flush() + except Exception: + pass + + arc = _archive_path(backup_dir) + if not os.path.isfile(arc): + log('Cannot restore: missing {0}'.format(arc)) + return False + try: + with tarfile.open(arc, 'r:gz') as tf: + for m in tf.getmembers(): + tf.extract(m, path='/', set_attrs=False) + log('Full restore from backup completed.') + return True + except Exception as err: + log('Full restore failed: {0}'.format(err)) + return False + + +def cleanup_managed_backup(backup_dir, status_file): + def log(msg): + try: + status_file.write(msg + '\n') + status_file.flush() + except Exception: + pass + + if not backup_dir or not os.path.isdir(backup_dir): + return + try: + shutil.rmtree(backup_dir, ignore_errors=True) + log('Removed backup directory after successful change: {0}'.format(backup_dir)) + except Exception as err: + log('Could not remove backup dir: {0}'.format(err)) + + +def chown_app_paths(app_name, status_writer): + cmd = CHOWN_CMDS.get(app_name) + if not cmd: + return + try: + subprocess.call(cmd, shell=True, stdout=status_writer, stderr=status_writer) + except Exception: + pass + + +def service_is_active(app_name): + unit = SERVICE_UNITS.get(app_name) + if not unit: + return False + try: + r = subprocess.run( + ['systemctl', 'is-active', unit], + capture_output=True, + text=True, + timeout=30, + ) + return r.stdout.strip() == 'active' + except Exception: + return False diff --git a/manageServices/application_detection.py b/manageServices/application_detection.py new file mode 100644 index 000000000..36162f04e --- /dev/null +++ b/manageServices/application_detection.py @@ -0,0 +1,190 @@ +import os +import re +import subprocess + + +APP_PACKAGE_MAP = { + 'Elasticsearch': { + 'rhel': 'elasticsearch', + 'debian': 'elasticsearch', + 'service': 'elasticsearch', + 'binary_paths': ['/usr/share/elasticsearch/bin/elasticsearch'] + }, + 'Redis': { + 'rhel': 'redis', + 'debian': 'redis-server', + 'service': 'redis', + 'binary_paths': ['/usr/bin/redis-server'] + }, + 'RabbitMQ': { + 'rhel': 'rabbitmq-server', + 'debian': 'rabbitmq-server', + 'service': 'rabbitmq-server', + 'binary_paths': ['/usr/sbin/rabbitmq-server', '/usr/lib/rabbitmq/bin/rabbitmq-server'] + } +} + +APP_MARKERS = { + 'Elasticsearch': '/home/cyberpanel/elasticsearch', + 'Redis': '/home/cyberpanel/redis', + 'RabbitMQ': '/home/cyberpanel/rabbitmq' +} + + +def _run(cmd): + try: + res = subprocess.run(cmd, capture_output=True, text=True, timeout=12) + return res.returncode, (res.stdout or '').strip(), (res.stderr or '').strip() + except Exception as err: + return 1, '', str(err) + + +def is_debian_family(): + return os.path.exists('/etc/debian_version') or os.path.exists('/etc/lsb-release') + + +def rhel_major_from_os_release(): + """ + RHEL-family OS major version (8, 9, 10, …) from /etc/os-release (or redhat-release). + Returns None for Debian/Ubuntu or if the OS cannot be classified as RHEL-like. + Used to align Packagecloud Yum baseurls (el/8 vs el/9) with the running system. + """ + if is_debian_family(): + return None + os_release = '/etc/os-release' + version_id = None + platform_id = None + if os.path.exists(os_release): + try: + with open(os_release, 'r', encoding='utf-8', errors='replace') as fh: + for line in fh: + line = line.strip() + if line.startswith('VERSION_ID='): + version_id = line.split('=', 1)[1].strip().strip('"').strip("'") + elif line.startswith('PLATFORM_ID='): + platform_id = line.split('=', 1)[1].strip().strip('"').strip("'") + except Exception: + pass + if version_id: + match = re.match(r'^(\d+)', version_id) + if match: + major = int(match.group(1)) + if 6 <= major <= 15: + return major + if platform_id: + match = re.search(r'el(\d+)', platform_id, re.IGNORECASE) + if match: + major = int(match.group(1)) + if 6 <= major <= 15: + return major + redhat_release = '/etc/redhat-release' + if os.path.exists(redhat_release): + try: + with open(redhat_release, 'r', encoding='utf-8', errors='replace') as fh: + txt = fh.read() + match = re.search(r'release\s+(\d+)', txt, re.IGNORECASE) + if match: + major = int(match.group(1)) + if 6 <= major <= 15: + return major + except Exception: + pass + return None + + +def is_centos7(): + release_paths = ['/etc/centos-release', '/etc/redhat-release', '/etc/os-release'] + text_blob = '' + for path in release_paths: + try: + if os.path.exists(path): + with open(path, 'r') as fh: + text_blob += fh.read().lower() + '\n' + except Exception: + continue + return ('centos' in text_blob and ('release 7' in text_blob or 'version_id="7' in text_blob)) + + +def managed_apps_os_support(): + if is_centos7(): + return { + 'supported': False, + 'reason': 'CentOS 7 is EOL and not supported for managed applications.' + } + return { + 'supported': True, + 'reason': '' + } + + +def package_name_for_app(app_name): + app_map = APP_PACKAGE_MAP.get(app_name, {}) + if not app_map: + return '' + if is_debian_family(): + return app_map.get('debian', '') + return app_map.get('rhel', '') + + +def _rpm_installed(pkg_name): + rc, out, _ = _run(['rpm', '-q', pkg_name]) + if rc == 0: + return True, out + return False, '' + + +def _dpkg_installed(pkg_name): + rc, out, _ = _run(['dpkg-query', '-W', '-f=${Version}', pkg_name]) + if rc == 0 and out: + return True, out + return False, '' + + +def _systemd_active(service_name): + rc, out, _ = _run(['systemctl', 'is-active', service_name]) + return rc == 0 and out.strip() == 'active' + + +def detect_installed_version(app_name): + pkg_name = package_name_for_app(app_name) + if not pkg_name: + return '' + + if is_debian_family(): + ok, ver = _dpkg_installed(pkg_name) + else: + ok, ver = _rpm_installed(pkg_name) + + if not ok: + return '' + + if app_name in ('Elasticsearch', 'Redis', 'RabbitMQ'): + match = re.search(r'(\d+\.\d+\.\d+)', ver) + return match.group(1) if match else ver + + return ver + + +def detect_app_state(app_name): + marker_path = APP_MARKERS.get(app_name, '') + package_name = package_name_for_app(app_name) + service_name = APP_PACKAGE_MAP.get(app_name, {}).get('service', '') + binary_paths = APP_PACKAGE_MAP.get(app_name, {}).get('binary_paths', []) + + installed_version = detect_installed_version(app_name) + marker_exists = bool(marker_path and os.path.exists(marker_path)) + service_active = _systemd_active(service_name) if service_name else False + binary_exists = any(os.path.exists(path) for path in binary_paths) + + installed = bool(installed_version or service_active or binary_exists) + + return { + 'appName': app_name, + 'packageName': package_name, + 'markerPath': marker_path, + 'markerExists': marker_exists, + 'installed': installed, + 'installedVersion': installed_version, + 'serviceActive': service_active, + 'binaryExists': binary_exists + } diff --git a/manageServices/application_elasticsearch.py b/manageServices/application_elasticsearch.py new file mode 100644 index 000000000..4f63ffd76 --- /dev/null +++ b/manageServices/application_elasticsearch.py @@ -0,0 +1,212 @@ +import os +import subprocess +import time + +from serverStatus.serverStatusUtil import ServerStatusUtil +from plogical import CyberCPLogFileWriter as logging +from manageServices.application_backup import ( + CHOWN_CMDS, + cleanup_managed_backup, + create_managed_app_backup, + merge_data_from_backup, + restore_full_backup, + service_is_active, +) +from manageServices.application_detection import detect_app_state, is_debian_family + + +def _es_major_normalized(es_major): + m = str(es_major).strip() + if m in ('7', '8', '9'): + return m + return '8' + + +def _write_repo(es_major): + major = _es_major_normalized(es_major) + if is_debian_family(): + repo_file = '/etc/apt/sources.list.d/elastic-{0}.x.list'.format(major) + cmd = 'echo "deb https://artifacts.elastic.co/packages/{0}.x/apt stable main" | sudo tee {1}'.format(major, repo_file) + subprocess.call(cmd, shell=True) + return repo_file + + repo_file = '/etc/yum.repos.d/elasticsearch.repo' + content = ''' +[elasticsearch] +name=Elasticsearch repository for {0}.x packages +baseurl=https://artifacts.elastic.co/packages/{0}.x/yum +gpgcheck=1 +gpgkey=https://artifacts.elastic.co/GPG-KEY-elasticsearch +enabled=0 +autorefresh=1 +type=rpm-md +'''.format(major) + with open(repo_file, 'w') as handle: + handle.write(content) + return repo_file + + +def _ensure_tmpdir(status_file): + ServerStatusUtil.executioner('mkdir -p /home/elasticsearch/tmp', status_file) + ServerStatusUtil.executioner('chown elasticsearch:elasticsearch /home/elasticsearch/tmp', status_file) + jvm_options = '/etc/elasticsearch/jvm.options' + line = '-Djava.io.tmpdir=/home/elasticsearch/tmp\n' + try: + if os.path.exists(jvm_options): + with open(jvm_options, 'r') as handle: + body = handle.read() + if line.strip() not in body: + with open(jvm_options, 'a') as handle: + handle.write(line) + except Exception: + pass + + +def adopt_or_reconcile(status_file): + state = detect_app_state('Elasticsearch') + if state['installed'] and not state['markerExists']: + ServerStatusUtil.executioner('touch /home/cyberpanel/elasticsearch', status_file) + logging.CyberCPLogFileWriter.statusWriter( + ServerStatusUtil.lswsInstallStatusPath, + 'Elasticsearch detected and adopted by marker reconciliation.\n' + ) + return state + + +def _resolve_target_version(version, es_major): + if version and str(version).strip() != 'latest': + return str(version).strip() + from manageServices.application_versions import get_latest_version + return get_latest_version('Elasticsearch', es_major, '3') or '' + + +def _run_elasticsearch_packages(version, es_major, status_file, allow_downgrade): + if is_debian_family(): + subprocess.call( + 'wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo apt-key add -', + shell=True, + ) + ServerStatusUtil.executioner('apt-get install apt-transport-https -y', status_file) + _write_repo(es_major) + ServerStatusUtil.executioner('apt-get update -y', status_file) + if version and version != 'latest': + cmd = ( + 'DEBIAN_FRONTEND=noninteractive apt-get install -y ' + '--allow-downgrades elasticsearch={0}' + ).format(version) + else: + cmd = 'DEBIAN_FRONTEND=noninteractive apt-get install elasticsearch -y' + ServerStatusUtil.executioner(cmd, status_file) + return + + ServerStatusUtil.executioner( + 'rpm --import https://artifacts.elastic.co/GPG-KEY-elasticsearch', status_file + ) + _write_repo(es_major) + ad = ' --allow-downgrade' if allow_downgrade else '' + if version and version != 'latest': + cmd = 'dnf install{0} -y --enablerepo=elasticsearch elasticsearch-{1}'.format( + ad, version + ) + ServerStatusUtil.executioner(cmd, status_file) + else: + cmd = 'dnf install{0} -y --enablerepo=elasticsearch elasticsearch'.format(ad) + ServerStatusUtil.executioner(cmd, status_file) + + +def install(version='latest', es_major='8'): + status_file = open(ServerStatusUtil.lswsInstallStatusPath, 'w') + adopt_or_reconcile(status_file) + + from manageServices.application_versions import version_compare + + state = detect_app_state('Elasticsearch') + backup_dir = '' + allow_downgrade = False + target = _resolve_target_version(version, es_major) + + if state['installed'] and state.get('installedVersion'): + status_file.write( + 'Pre-version-change backup and service stop (Elasticsearch)...\n' + ) + status_file.flush() + iv = state['installedVersion'] + if target and version_compare(iv, target) > 0: + allow_downgrade = True + status_file.write( + 'Downgrade path: allowing package manager downgrade where supported.\n' + ) + status_file.flush() + backup_dir = create_managed_app_backup('Elasticsearch', status_file) + ServerStatusUtil.executioner('systemctl stop elasticsearch', status_file) + + _run_elasticsearch_packages(version, es_major, status_file, allow_downgrade) + if backup_dir: + merge_data_from_backup('Elasticsearch', backup_dir, status_file) + ServerStatusUtil.executioner(CHOWN_CMDS['Elasticsearch'], status_file) + + _ensure_tmpdir(status_file) + ServerStatusUtil.executioner('systemctl enable elasticsearch', status_file) + ServerStatusUtil.executioner('systemctl start elasticsearch', status_file) + time.sleep(3) + + if backup_dir: + if service_is_active('Elasticsearch'): + cleanup_managed_backup(backup_dir, status_file) + status_file.write( + 'Elasticsearch version change completed; backup removed after success.\n' + ) + else: + status_file.write( + 'Elasticsearch failed to start; restoring from backup...\n' + ) + restore_full_backup(backup_dir, status_file) + ServerStatusUtil.executioner(CHOWN_CMDS['Elasticsearch'], status_file) + ServerStatusUtil.executioner('systemctl start elasticsearch', status_file) + time.sleep(2) + if not service_is_active('Elasticsearch'): + status_file.write( + 'Recovery unclear — backup kept at {0}\n'.format(backup_dir) + ) + else: + status_file.write( + 'Prior state restored from backup. Backup retained for safety.\n' + ) + status_file.flush() + + ServerStatusUtil.executioner('touch /home/cyberpanel/elasticsearch', status_file) + logging.CyberCPLogFileWriter.statusWriter( + ServerStatusUtil.lswsInstallStatusPath, 'Elasticsearch installed.[200]\n', 1 + ) + return 0 + + +def upgrade(version='latest', es_major='8'): + return install(version=version, es_major=es_major) + + +def remove(): + status_file = open(ServerStatusUtil.lswsInstallStatusPath, 'w') + if is_debian_family(): + for major in ('7', '8', '9'): + path = '/etc/apt/sources.list.d/elastic-{0}.x.list'.format(major) + try: + os.remove(path) + except Exception: + pass + ServerStatusUtil.executioner( + 'DEBIAN_FRONTEND=noninteractive apt-get remove elasticsearch -y', status_file + ) + else: + try: + os.remove('/etc/yum.repos.d/elasticsearch.repo') + except Exception: + pass + ServerStatusUtil.executioner('yum erase elasticsearch -y', status_file) + + ServerStatusUtil.executioner('rm -rf /home/elasticsearch/tmp', status_file) + ServerStatusUtil.executioner('rm -f /home/cyberpanel/elasticsearch', status_file) + logging.CyberCPLogFileWriter.statusWriter( + ServerStatusUtil.lswsInstallStatusPath, 'Elasticsearch removed.[200]\n', 1 + ) + return 0 diff --git a/manageServices/application_page_meta.py b/manageServices/application_page_meta.py new file mode 100644 index 000000000..c64626dbb --- /dev/null +++ b/manageServices/application_page_meta.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +"""Server-side metadata for Manage Applications page (version lists in HTML).""" +import json +import os +import threading +import time + +from django.utils.translation import gettext as _ + +from .application_detection import detect_app_state, managed_apps_os_support +from .application_versions import get_available_versions, version_compare + +_APP_IMAGES = { + 'Elasticsearch': '/static/manageServices/images/elastic-search.png', + 'Redis': '/static/manageServices/images/redis.png', + 'RabbitMQ': '/static/manageServices/images/rabbitmq-logo.svg', +} + +# Cache only repoquery/dnf-backed version lists (slow). Install state is always refreshed. +# Override with CYBERCP_MANAGED_APPS_VERSIONS_INVENTORY_TTL (seconds), default 3600 (1 hour). +_VERSIONS_INVENTORY_TTL_SECONDS = int( + os.environ.get('CYBERCP_MANAGED_APPS_VERSIONS_INVENTORY_TTL', '3600') +) +_VERSIONS_INVENTORY_CACHE = {} +_VERSIONS_INVENTORY_LOCK = threading.Lock() + + +def _versions_inventory_cache_get(cache_key): + now = time.time() + with _VERSIONS_INVENTORY_LOCK: + item = _VERSIONS_INVENTORY_CACHE.get(cache_key) + if not item: + return None + ts, inventory = item + if now - ts > _VERSIONS_INVENTORY_TTL_SECONDS: + try: + del _VERSIONS_INVENTORY_CACHE[cache_key] + except Exception: + pass + return None + return {k: list(v) for k, v in inventory.items()} + + +def _versions_inventory_cache_put(cache_key, inventory): + with _VERSIONS_INVENTORY_LOCK: + if len(_VERSIONS_INVENTORY_CACHE) > 16: + _VERSIONS_INVENTORY_CACHE.clear() + snap = {k: list(v) for k, v in (inventory or {}).items()} + _VERSIONS_INVENTORY_CACHE[cache_key] = (time.time(), snap) + + +def _cold_fetch_version_inventory(major, rmq, support): + """Populate version lists from package managers (DNF/apt); can take many seconds.""" + inv = {} + if not support.get('supported'): + for app_name in ('Elasticsearch', 'Redis', 'RabbitMQ'): + inv[app_name] = [] + return inv + for app_name in ('Elasticsearch', 'Redis', 'RabbitMQ'): + try: + inv[app_name] = get_available_versions(app_name, major, rmq) + except BaseException: + inv[app_name] = [] + return inv + + +def _resolve_version_inventory(cache_key, major, rmq, support): + cached = _versions_inventory_cache_get(cache_key) + if cached is not None: + return cached + inv = _cold_fetch_version_inventory(major, rmq, support) + _versions_inventory_cache_put(cache_key, inv) + return inv + + +def _assemble_manage_applications_payload(major, rmq, support, version_inv): + """Build services + bootstrap JSON from fresh install state and cached (or new) version lists.""" + services = [] + bootstrap_apps = [] + + for app_name in ('Elasticsearch', 'Redis', 'RabbitMQ'): + state = detect_app_state(app_name) + services.append({ + 'image': _APP_IMAGES[app_name], + 'name': app_name, + 'installed': 'Installed' if state['installed'] else 'Not-Installed', + 'installedVersion': state.get('installedVersion', ''), + }) + + versions = list(version_inv.get(app_name) or []) + latest_branch = '' + latest_global = '' + if versions: + latest_branch = versions[0] + latest_global = latest_branch + + installed_version = state['installedVersion'] + if installed_version and installed_version not in versions: + prepend_installed = True + if app_name == 'RabbitMQ': + from manageServices.application_rabbitmq_repo import ( + filter_versions_for_stream, + ) + prepend_installed = bool( + filter_versions_for_stream([installed_version], rmq) + ) + if prepend_installed: + versions = [installed_version] + versions + + ref_latest = latest_global or latest_branch + update_available = bool( + state['installed'] + and installed_version + and ref_latest + and version_compare(installed_version, ref_latest) < 0 + ) + + rabbitmq_versions_hint = '' + if app_name == 'RabbitMQ' and not versions: + if rmq == '4': + rabbitmq_versions_hint = _( + 'Your OS is not unsupported: upstream RabbitMQ publishes 4.x RPMs suitable for ' + 'RHEL/Alma/Rocky 8 and 9 (RPM filenames may still contain el8; that is normal). ' + 'If this list stays empty, repository metadata may not expose 4.x to dnf yet—' + 'refresh metadata (dnf makecache -y) or install the official .rpm from rabbitmq.com. ' + 'Check with: dnf repoquery rabbitmq-server --available --show-duplicates ' + '(4.x lines look like rabbitmq-server-0:4.x.y-1.el8.noarch — search for :4., not a space after the colon).' + ) + else: + rabbitmq_versions_hint = _( + 'No 3.x builds were returned for this stream after refreshing Team RabbitMQ repos. ' + 'This is usually metadata or repo state—not OS support. Try: dnf makecache -y, ' + 'then dnf repoquery rabbitmq-server --available --show-duplicates.' + ) + + bootstrap_apps.append({ + 'name': app_name, + 'installed': state['installed'], + 'installedVersion': installed_version, + 'latestAvailable': latest_branch, + 'latestOverall': latest_global, + 'updateAvailable': update_available, + 'crossBranchUpdateSuggested': False, + 'versions': versions, + 'packageName': state['packageName'], + 'adopted': bool(state['installed'] and not state['markerExists']), + 'major': major if app_name == 'Elasticsearch' else '', + 'rabbitmqStream': rmq if app_name == 'RabbitMQ' else '', + 'rabbitmqVersionsHint': rabbitmq_versions_hint, + }) + + bootstrap = {'status': 1, 'apps': bootstrap_apps} + meta_json = json.dumps(bootstrap, ensure_ascii=False) + return services, meta_json + + +def build_manage_applications_page_data(es_major='8', rabbitmq_stream='4'): + """ + Build `services` for card HTML and a JSON-serializable bootstrap matching + /manageservices/applicationMeta shape (default ES major 8, RMQ stream 4). + + Version lists are cached for _VERSIONS_INVENTORY_TTL_SECONDS to avoid repeated + DNF/repoquery on every page view; install status is always detected live. + """ + support = managed_apps_os_support() + major = str(es_major).strip() if str(es_major).strip() in ('7', '8', '9') else '8' + rmq = str(rabbitmq_stream).strip() if str(rabbitmq_stream).strip() in ('3', '4') else '4' + cache_key = 'major:{0}|rmq:{1}|support:{2}'.format( + major, rmq, 1 if support.get('supported') else 0 + ) + + version_inv = _resolve_version_inventory(cache_key, major, rmq, support) + return _assemble_manage_applications_payload(major, rmq, support, version_inv) + + +def get_application_meta_response_dict(es_major='8', rabbitmq_stream='4'): + """ + JSON payload for POST /manageservices/applicationMeta. + Shares the same version-list inventory cache as the Manage Applications HTML bootstrap. + """ + support = managed_apps_os_support() + major = str(es_major).strip() if str(es_major).strip() in ('7', '8', '9') else '8' + rmq = str(rabbitmq_stream).strip() if str(rabbitmq_stream).strip() in ('3', '4') else '4' + + _, meta_json = build_manage_applications_page_data(major, rmq) + payload = json.loads(meta_json) + return { + 'status': 1, + 'osSupportedForManagedApps': support['supported'], + 'unsupportedReason': support['reason'], + 'apps': payload.get('apps') or [], + } diff --git a/manageServices/application_rabbitmq.py b/manageServices/application_rabbitmq.py new file mode 100644 index 000000000..d3c44f139 --- /dev/null +++ b/manageServices/application_rabbitmq.py @@ -0,0 +1,146 @@ +import time + +from serverStatus.serverStatusUtil import ServerStatusUtil +from plogical import CyberCPLogFileWriter as logging + +from manageServices.application_backup import ( + CHOWN_CMDS, + cleanup_managed_backup, + create_managed_app_backup, + merge_data_from_backup, + restore_full_backup, + service_is_active, +) +from manageServices.application_detection import detect_app_state, is_debian_family +from manageServices.application_rabbitmq_repo import ( + normalize_rabbitmq_stream, + ensure_rabbitmq_team_repos, + ensure_erlang_meets_minimum, +) + + +def adopt_or_reconcile(status_file): + state = detect_app_state('RabbitMQ') + if state['installed'] and not state['markerExists']: + ServerStatusUtil.executioner('touch /home/cyberpanel/rabbitmq', status_file) + logging.CyberCPLogFileWriter.statusWriter( + ServerStatusUtil.lswsInstallStatusPath, + 'RabbitMQ detected and adopted by marker reconciliation.\n' + ) + return state + + +def _resolve_target_version(version, stream): + if version and str(version).strip() != 'latest': + return str(version).strip() + from manageServices.application_versions import get_latest_version + return get_latest_version('RabbitMQ', '8', stream) or '' + + +def _run_rabbitmq_packages(version, status_file, allow_downgrade): + ad = ' --allow-downgrade' if allow_downgrade else '' + if is_debian_family(): + if version and version != 'latest': + cmd = ( + 'DEBIAN_FRONTEND=noninteractive apt-get install -y ' + '--allow-downgrades rabbitmq-server={0}' + ).format(version) + else: + cmd = 'DEBIAN_FRONTEND=noninteractive apt-get install rabbitmq-server -y' + ServerStatusUtil.executioner(cmd, status_file) + return + + if version and version != 'latest': + cmd = 'dnf install{0} -y rabbitmq-server-{1}'.format(ad, version) + ServerStatusUtil.executioner(cmd, status_file) + else: + cmd = 'dnf install{0} -y rabbitmq-server'.format(ad) + ServerStatusUtil.executioner(cmd, status_file) + + +def install(version='latest', stream='3'): + stream = normalize_rabbitmq_stream(stream) + status_file = open(ServerStatusUtil.lswsInstallStatusPath, 'w') + adopt_or_reconcile(status_file) + + from manageServices.application_versions import version_compare + + ensure_rabbitmq_team_repos(stream, status_file=status_file) + ensure_erlang_meets_minimum(stream, version, status_file=status_file) + + state = detect_app_state('RabbitMQ') + backup_dir = '' + allow_downgrade = False + target = _resolve_target_version(version, stream) + + if state['installed'] and state.get('installedVersion'): + status_file.write( + 'Pre-version-change backup and service stop (RabbitMQ)...\n' + ) + status_file.flush() + iv = state['installedVersion'] + if target and version_compare(iv, target) > 0: + allow_downgrade = True + status_file.write('Downgrade path enabled for RabbitMQ.\n') + status_file.flush() + backup_dir = create_managed_app_backup('RabbitMQ', status_file) + ServerStatusUtil.executioner('systemctl stop rabbitmq-server', status_file) + + _run_rabbitmq_packages(version, status_file, allow_downgrade) + if backup_dir: + merge_data_from_backup('RabbitMQ', backup_dir, status_file) + ServerStatusUtil.executioner(CHOWN_CMDS['RabbitMQ'], status_file) + + ServerStatusUtil.executioner('systemctl enable rabbitmq-server', status_file) + ServerStatusUtil.executioner('systemctl start rabbitmq-server', status_file) + time.sleep(4) + + if backup_dir: + if service_is_active('RabbitMQ'): + cleanup_managed_backup(backup_dir, status_file) + status_file.write( + 'RabbitMQ version change completed; backup removed after success.\n' + ) + else: + status_file.write('RabbitMQ failed to start; restoring from backup...\n') + restore_full_backup(backup_dir, status_file) + ServerStatusUtil.executioner(CHOWN_CMDS['RabbitMQ'], status_file) + ServerStatusUtil.executioner('systemctl start rabbitmq-server', status_file) + time.sleep(4) + if not service_is_active('RabbitMQ'): + status_file.write( + 'Recovery unclear — backup kept at {0}\n'.format(backup_dir) + ) + else: + status_file.write( + 'Prior state restored from backup. Backup retained for safety.\n' + ) + status_file.flush() + + ServerStatusUtil.executioner('touch /home/cyberpanel/rabbitmq', status_file) + logging.CyberCPLogFileWriter.statusWriter( + ServerStatusUtil.lswsInstallStatusPath, 'RabbitMQ installed.[200]\n', 1 + ) + return 0 + + +def upgrade(version='latest', stream='3'): + return install(version=version, stream=stream) + + +def remove(): + status_file = open(ServerStatusUtil.lswsInstallStatusPath, 'w') + ServerStatusUtil.executioner('systemctl stop rabbitmq-server', status_file) + ServerStatusUtil.executioner('systemctl disable rabbitmq-server', status_file) + if is_debian_family(): + ServerStatusUtil.executioner( + 'DEBIAN_FRONTEND=noninteractive apt-get remove rabbitmq-server -y', + status_file, + ) + else: + ServerStatusUtil.executioner('yum erase rabbitmq-server -y', status_file) + ServerStatusUtil.executioner('rm -f /home/cyberpanel/rabbitmq', status_file) + logging.CyberCPLogFileWriter.statusWriter( + ServerStatusUtil.lswsInstallStatusPath, 'RabbitMQ removed.[200]\n', 1 + ) + return 0 diff --git a/manageServices/application_rabbitmq_repo.py b/manageServices/application_rabbitmq_repo.py new file mode 100644 index 000000000..11c623d5e --- /dev/null +++ b/manageServices/application_rabbitmq_repo.py @@ -0,0 +1,422 @@ +# -*- coding: utf-8 -*- +""" +Team RabbitMQ package repositories (Packagecloud) and Erlang compatibility +for RabbitMQ 3.x vs 4.x installation streams. +""" +import os +import re +import subprocess +import tempfile +import time + +from manageServices.application_detection import is_debian_family, rhel_major_from_os_release + +# Official Packagecloud install scripts (RabbitMQ team). +_RPM_ERLANG_SCRIPT = ( + 'https://packagecloud.io/install/repositories/rabbitmq/rabbitmq-erlang/script.rpm.sh' +) +_RPM_SERVER_SCRIPT = ( + 'https://packagecloud.io/install/repositories/rabbitmq/rabbitmq-server/script.rpm.sh' +) +_DEB_ERLANG_SCRIPT = ( + 'https://packagecloud.io/install/repositories/rabbitmq/rabbitmq-erlang/script.deb.sh' +) +_DEB_SERVER_SCRIPT = ( + 'https://packagecloud.io/install/repositories/rabbitmq/rabbitmq-server/script.deb.sh' +) + +# Minimum OTP major for each product stream (see rabbitmq.com docs / compatibility). +_MIN_OTP_STREAM_3 = 25 +_MIN_OTP_STREAM_4 = 26 + +# When Packagecloud metadata lists 3.x but no 4.x (common on el/9 trees), still offer GA +# releases from https://www.rabbitmq.com/release-information so the panel can run +# dnf install rabbitmq-server- (RPMs are often el8-tagged on EL9 per upstream docs). +# Update this tuple when new 4.x patches ship. +RABBITMQ_4X_METADATA_FALLBACK_VERSIONS = ( + '4.2.5', + '4.2.4', + '4.2.3', + '4.2.2', + '4.2.1', + '4.2.0', + '4.1.8', + '4.1.7', + '4.1.6', + '4.1.5', + '4.1.4', + '4.1.3', + '4.1.2', + '4.1.1', + '4.1.0', + '4.0.9', + '4.0.8', + '4.0.7', + '4.0.6', + '4.0.5', + '4.0.4', + '4.0.3', + '4.0.2', + '4.0.1', + '4.0.0', +) + +_YUM_REPOS_D = '/etc/yum.repos.d' +# Packagecloud RabbitMQ repos use .../el/N/... in baseurl; must match host RHEL major. +_EL_URL_SEGMENT = re.compile(r'(/el/)(\d+)(/)') + + +def _run(cmd, timeout=300): + try: + res = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout, shell=False + ) + return res.returncode, (res.stdout or ''), (res.stderr or '') + except Exception as err: + return 1, '', str(err) + + +def _run_shell_trusted(script_url, timeout=300): + """Run packagecloud install script from fixed RabbitMQ-team URL only.""" + allowed = {_RPM_ERLANG_SCRIPT, _RPM_SERVER_SCRIPT, _DEB_ERLANG_SCRIPT, _DEB_SERVER_SCRIPT} + if script_url not in allowed: + return 1, '', 'Invalid repository script URL.' + # curl -fsSL ... | bash (URLs are allowlisted above) + cmd = 'curl -1fsSL {0} | bash'.format(script_url) + return _run(['/bin/bash', '-lc', cmd], timeout=timeout) + + +def normalize_rabbitmq_stream(value): + s = str(value or '4').strip() + if s in ('4', '4.x', '41', '4.1'): + return '4' + return '3' + + +def _write_status(status_file, message): + if status_file is None: + return + try: + status_file.write(message + '\n') + status_file.flush() + except Exception: + pass + + +def _rhel_refresh_package_metadata(status_file=None, aggressive=False): + """ + Refresh DNF/YUM metadata after adding Packagecloud repos. + Retries on failure. When aggressive (e.g. 4.x stream), expire cache first + so new rabbitmq-server builds become visible. + """ + if is_debian_family(): + return True + if aggressive: + exp_rc, _, exp_err = _run(['dnf', 'clean', 'expire-cache'], timeout=90) + if exp_rc != 0: + _write_status( + status_file, + 'dnf expire-cache (non-fatal): ' + (exp_err or '')[:120] + ) + last_err = '' + for attempt in range(1, 4): + for cache_cmd in (['dnf', 'makecache', '-y'], ['yum', 'makecache', '-y']): + c_rc, c_out, c_err = _run(cache_cmd, timeout=180) + if c_rc == 0: + _write_status( + status_file, + 'RPM metadata refreshed ({0}, attempt {1}).'.format( + cache_cmd[0], attempt + ) + ) + return True + last_err = (c_err or c_out or str(c_rc)).strip() + time.sleep(min(3 * attempt, 15)) + _write_status( + status_file, + 'RPM metadata refresh failed after retries: ' + (last_err or 'unknown')[:240] + ) + return False + + +def refresh_rhel_metadata_for_rabbitmq_repos(status_file=None): + """ + Public: force another metadata refresh (e.g. when repoquery finds no 4.x RPMs). + """ + return _rhel_refresh_package_metadata(status_file=status_file, aggressive=True) + + +def align_rabbitmq_packagecloud_repos_to_os(status_file=None): + """ + If Team RabbitMQ Packagecloud .repo files point at /el/M/ but this host is el/N, + rewrite URLs to /el/N/ (e.g. stale el/8 on AlmaLinux 9). Only touches files that + mention both packagecloud.io and rabbitmq. Requires root to write /etc/yum.repos.d. + """ + if is_debian_family(): + return + target_major = rhel_major_from_os_release() + if target_major is None: + return + if not os.path.isdir(_YUM_REPOS_D): + return + try: + repo_names = sorted( + n for n in os.listdir(_YUM_REPOS_D) if n.endswith('.repo') + ) + except OSError as err: + _write_status( + status_file, + 'rabbitmq repo align: cannot list {0}: {1}'.format( + _YUM_REPOS_D, str(err)[:100] + ) + ) + return + + for repo_name in repo_names: + repo_path = os.path.join(_YUM_REPOS_D, repo_name) + try: + with open(repo_path, 'r', encoding='utf-8', errors='replace') as handle: + original = handle.read() + except OSError: + continue + lower = original.lower() + if 'packagecloud.io' not in lower or 'rabbitmq' not in lower: + continue + + def _sub_el(match): + current = int(match.group(2)) + if current == target_major: + return match.group(0) + return match.group(1) + str(target_major) + match.group(3) + + updated = _EL_URL_SEGMENT.sub(_sub_el, original) + if updated == original: + continue + tmp_path = None + try: + fd, tmp_path = tempfile.mkstemp( + prefix='.cybercp-rabbitmq-', + suffix='.tmp', + dir=_YUM_REPOS_D, + text=True, + ) + with os.fdopen(fd, 'w', encoding='utf-8') as out: + out.write(updated) + os.replace(tmp_path, repo_path) + tmp_path = None + _write_status( + status_file, + 'Aligned RabbitMQ Packagecloud repo {0} to el/{1}.'.format( + repo_name, target_major + ) + ) + except PermissionError: + _write_status( + status_file, + 'rabbitmq repo align: need root to rewrite {0} (el/{1}).'.format( + repo_name, target_major + ) + ) + except OSError as err: + _write_status( + status_file, + 'rabbitmq repo align: {0}: {1}'.format(repo_name, str(err)[:120]) + ) + finally: + if tmp_path and os.path.isfile(tmp_path): + try: + os.unlink(tmp_path) + except OSError: + pass + + +def refresh_debian_apt_metadata(status_file=None): + """Second-chance apt metadata refresh without re-running Packagecloud scripts.""" + if not is_debian_family(): + return True + last_err = '' + for apt_attempt in range(1, 4): + a_rc, _, a_err = _run(['apt-get', 'update', '-y'], timeout=180) + if a_rc == 0: + _write_status( + status_file, + 'APT metadata refreshed (attempt {0}).'.format(apt_attempt) + ) + return True + last_err = (a_err or '').strip() + _write_status( + status_file, + 'apt-get update attempt {0}: {1}'.format(apt_attempt, (last_err or '')[:160]) + ) + time.sleep(min(3 * apt_attempt, 12)) + return False + + +def ensure_rabbitmq_team_repos(stream, status_file=None): + """ + Idempotently enable rabbitmq-erlang and rabbitmq-server Packagecloud repos. + Required so 3.13.x, 4.x, and matching Erlang builds are visible to the + package manager. + """ + stream = normalize_rabbitmq_stream(stream) + _write_status( + status_file, + 'Ensuring Team RabbitMQ repositories (stream {0})...'.format(stream) + ) + if is_debian_family(): + rc, out, err = _run_shell_trusted(_DEB_ERLANG_SCRIPT) + if rc != 0: + _write_status(status_file, 'rabbitmq-erlang repo script: ' + (err or out or 'failed')) + rc2, out2, err2 = _run_shell_trusted(_DEB_SERVER_SCRIPT) + if rc2 != 0: + _write_status( + status_file, 'rabbitmq-server repo script: ' + (err2 or out2 or 'failed') + ) + for apt_attempt in range(1, 4): + a_rc, _, a_err = _run(['apt-get', 'update', '-y'], timeout=180) + if a_rc == 0: + break + _write_status( + status_file, + 'apt-get update attempt {0}: {1}'.format(apt_attempt, (a_err or '')[:160]) + ) + time.sleep(min(3 * apt_attempt, 12)) + else: + align_rabbitmq_packagecloud_repos_to_os(status_file=status_file) + rc, out, err = _run_shell_trusted(_RPM_ERLANG_SCRIPT) + if rc != 0: + _write_status(status_file, 'rabbitmq-erlang repo script: ' + (err or out or 'failed')) + rc2, out2, err2 = _run_shell_trusted(_RPM_SERVER_SCRIPT) + if rc2 != 0: + _write_status( + status_file, 'rabbitmq-server repo script: ' + (err2 or out2 or 'failed') + ) + # 4.x builds may appear after a fresh metadata pull; expire + retries help visibility. + _rhel_refresh_package_metadata( + status_file=status_file, + aggressive=(stream == '4'), + ) + _write_status(status_file, 'Team RabbitMQ repositories ready.') + + +def get_erlang_otp_major(): + """Best-effort current Erlang/OTP major version (integer or 0 if unknown).""" + rc, out, _ = _run( + [ + 'erl', + '-noshell', + '-eval', + 'io:format("~s~n", [erlang:system_info(otp_release)]), halt().', + ], + timeout=15, + ) + if rc == 0 and out: + m = re.search(r'(\d+)', out.strip()) + if m: + return int(m.group(1)) + # rpm: erlang from RabbitMQ repo may report R26 flavour + rc2, out2, _ = _run(['rpm', '-q', '--qf', '%{VERSION}', 'erlang'], timeout=10) + if rc2 == 0 and out2: + m = re.search(r'^(\d+)', out2.strip()) + if m: + return int(m.group(1)) + return 0 + + +def minimum_otp_for_stream(stream): + stream = normalize_rabbitmq_stream(stream) + if stream == '4': + return _MIN_OTP_STREAM_4 + return _MIN_OTP_STREAM_3 + + +def minimum_otp_for_rabbitmq_version(version_str): + """Infer OTP floor from chosen RabbitMQ version when possible.""" + if not version_str or version_str == 'latest': + return None + m = re.match(r'^(\d+)', str(version_str).strip()) + if not m: + return None + major = int(m.group(1)) + if major >= 4: + return _MIN_OTP_STREAM_4 + if major >= 3: + return _MIN_OTP_STREAM_3 + return None + + +def ensure_erlang_meets_minimum(stream, version, status_file=None): + """ + Upgrade/install Erlang from enabled repos if OTP is below the minimum + for the selected RabbitMQ stream or explicit target version. + """ + stream = normalize_rabbitmq_stream(stream) + need = minimum_otp_for_stream(stream) + version_floor = minimum_otp_for_rabbitmq_version(version) + if version_floor is not None: + need = max(need, version_floor) + + current = get_erlang_otp_major() + if current >= need: + _write_status( + status_file, + 'Erlang/OTP {0} satisfies minimum {1} for this RabbitMQ target.'.format( + current or 'unknown', need + ) + ) + return + + _write_status( + status_file, + 'Erlang/OTP {0} is below required {1}; installing/upgrading erlang from Team RabbitMQ repo...'.format( + current or 'unknown', need + ) + ) + if is_debian_family(): + _run( + [ + '/bin/bash', + '-lc', + 'DEBIAN_FRONTEND=noninteractive apt-get install -y erlang', + ], + timeout=600, + ) + else: + for inst in ( + ['dnf', 'install', '-y', 'erlang'], + ['yum', 'install', '-y', 'erlang'], + ): + rc, _, _ = _run(inst, timeout=600) + if rc == 0: + break + + after = get_erlang_otp_major() + if after < need: + _write_status( + status_file, + 'WARNING: Erlang may still be below OTP {0} (reported {1}). ' + 'Check /root/cyberpanel or logs and install correct erlang package.'.format( + need, after or 'unknown' + ) + ) + else: + _write_status(status_file, 'Erlang/OTP updated to {0}.'.format(after)) + + +def filter_versions_for_stream(versions, stream): + """Keep only versions whose major matches RabbitMQ stream (3 or 4).""" + stream = normalize_rabbitmq_stream(stream) + result = [] + seen = set() + for raw in versions or []: + v = (raw or '').strip() + if not v or v in seen: + continue + if v == 'latest': + continue + m = re.search(r'(\d+)\.(\d+)', v) + if m and m.group(1) == stream: + seen.add(v) + result.append(v) + # Preserve descending-ish order (caller already sorted newest first) + return result diff --git a/manageServices/application_redis.py b/manageServices/application_redis.py new file mode 100644 index 000000000..2dd2e46a8 --- /dev/null +++ b/manageServices/application_redis.py @@ -0,0 +1,132 @@ +import time + +from serverStatus.serverStatusUtil import ServerStatusUtil +from plogical import CyberCPLogFileWriter as logging + +from manageServices.application_backup import ( + CHOWN_CMDS, + cleanup_managed_backup, + create_managed_app_backup, + merge_data_from_backup, + restore_full_backup, + service_is_active, +) +from manageServices.application_detection import detect_app_state, is_debian_family + + +def adopt_or_reconcile(status_file): + state = detect_app_state('Redis') + if state['installed'] and not state['markerExists']: + ServerStatusUtil.executioner('touch /home/cyberpanel/redis', status_file) + logging.CyberCPLogFileWriter.statusWriter( + ServerStatusUtil.lswsInstallStatusPath, + 'Redis detected and adopted by marker reconciliation.\n' + ) + return state + + +def _resolve_target_version(version): + if version and str(version).strip() != 'latest': + return str(version).strip() + from manageServices.application_versions import get_latest_version + return get_latest_version('Redis', '8', '3') or '' + + +def _run_redis_packages(version, status_file, allow_downgrade): + ad = ' --allow-downgrade' if allow_downgrade else '' + if is_debian_family(): + if version and version != 'latest': + cmd = ( + 'DEBIAN_FRONTEND=noninteractive apt-get install -y ' + '--allow-downgrades redis-server={0}' + ).format(version) + else: + cmd = 'DEBIAN_FRONTEND=noninteractive apt-get install redis-server -y' + ServerStatusUtil.executioner(cmd, status_file) + return + + if version and version != 'latest': + cmd = 'dnf install{0} -y redis-{1}'.format(ad, version) + ServerStatusUtil.executioner(cmd, status_file) + else: + cmd = 'dnf install{0} -y redis'.format(ad) + ServerStatusUtil.executioner(cmd, status_file) + + +def install(version='latest'): + status_file = open(ServerStatusUtil.lswsInstallStatusPath, 'w') + adopt_or_reconcile(status_file) + + from manageServices.application_versions import version_compare + + state = detect_app_state('Redis') + backup_dir = '' + allow_downgrade = False + target = _resolve_target_version(version) + + if state['installed'] and state.get('installedVersion'): + status_file.write('Pre-version-change backup and service stop (Redis)...\n') + status_file.flush() + iv = state['installedVersion'] + if target and version_compare(iv, target) > 0: + allow_downgrade = True + status_file.write('Downgrade path enabled for Redis.\n') + status_file.flush() + backup_dir = create_managed_app_backup('Redis', status_file) + ServerStatusUtil.executioner('systemctl stop redis', status_file) + + _run_redis_packages(version, status_file, allow_downgrade) + if backup_dir: + merge_data_from_backup('Redis', backup_dir, status_file) + ServerStatusUtil.executioner(CHOWN_CMDS['Redis'], status_file) + + ServerStatusUtil.executioner('systemctl enable redis', status_file) + ServerStatusUtil.executioner('systemctl start redis', status_file) + time.sleep(2) + + if backup_dir: + if service_is_active('Redis'): + cleanup_managed_backup(backup_dir, status_file) + status_file.write( + 'Redis version change completed; backup removed after success.\n' + ) + else: + status_file.write('Redis failed to start; restoring from backup...\n') + restore_full_backup(backup_dir, status_file) + ServerStatusUtil.executioner(CHOWN_CMDS['Redis'], status_file) + ServerStatusUtil.executioner('systemctl start redis', status_file) + time.sleep(2) + if not service_is_active('Redis'): + status_file.write( + 'Recovery unclear — backup kept at {0}\n'.format(backup_dir) + ) + else: + status_file.write( + 'Prior state restored from backup. Backup retained for safety.\n' + ) + status_file.flush() + + ServerStatusUtil.executioner('touch /home/cyberpanel/redis', status_file) + logging.CyberCPLogFileWriter.statusWriter( + ServerStatusUtil.lswsInstallStatusPath, 'Redis installed.[200]\n', 1 + ) + return 0 + + +def upgrade(version='latest'): + return install(version=version) + + +def remove(): + status_file = open(ServerStatusUtil.lswsInstallStatusPath, 'w') + if is_debian_family(): + ServerStatusUtil.executioner( + 'DEBIAN_FRONTEND=noninteractive apt-get remove redis-server -y', status_file + ) + else: + ServerStatusUtil.executioner('yum erase redis -y', status_file) + ServerStatusUtil.executioner('rm -f /home/cyberpanel/redis', status_file) + logging.CyberCPLogFileWriter.statusWriter( + ServerStatusUtil.lswsInstallStatusPath, 'Redis removed.[200]\n', 1 + ) + return 0 diff --git a/manageServices/application_versions.py b/manageServices/application_versions.py new file mode 100644 index 000000000..d09dee212 --- /dev/null +++ b/manageServices/application_versions.py @@ -0,0 +1,475 @@ +import os +import platform +import re +import subprocess +import threading +import time + +from manageServices.application_detection import ( + is_debian_family, + package_name_for_app, + rhel_major_from_os_release, +) + +# applicationMeta can call get_available_versions many times per request (ES 7/8/9, RMQ 3/4). +# Concurrent DNF from every WSGI worker exhausts lscpd and returns HTTP 503. Cache + serialize cold fetches. +_VERSION_CACHE = {} +_VERSION_CACHE_LOCK = threading.Lock() +_DNF_COLD_FETCH_LOCK = threading.Lock() + +# Seconds; override with CYBERCP_MANAGED_APPS_VERSION_CACHE_TTL if needed. +# Default 3600 matches Manage Applications version-inventory TTL (reduces DNF after cache expiry). +_CACHE_TTL_SEC = int(os.environ.get('CYBERCP_MANAGED_APPS_VERSION_CACHE_TTL', '3600')) + + +def _version_cache_key(app_name, es_major, rabbitmq_stream): + debian = is_debian_family() + if app_name == 'Elasticsearch': + em = normalize_elasticsearch_major(es_major) + else: + em = '' + rs = '' + if app_name == 'RabbitMQ': + from manageServices.application_rabbitmq_repo import normalize_rabbitmq_stream + rs = normalize_rabbitmq_stream(rabbitmq_stream) + return (str(app_name), em, rs, debian) + + +def _cache_get_versions(key): + now = time.monotonic() + with _VERSION_CACHE_LOCK: + entry = _VERSION_CACHE.get(key) + if not entry: + return None + ts, versions = entry + if (now - ts) >= _CACHE_TTL_SEC: + try: + del _VERSION_CACHE[key] + except KeyError: + pass + return None + # Never use a poisoned empty cache (DNF timeout / lock) as a hit. + if not versions: + try: + del _VERSION_CACHE[key] + except KeyError: + pass + return None + return list(versions) + + +def _cache_put_versions(key, versions): + snap = list(versions or []) + if not snap: + return + with _VERSION_CACHE_LOCK: + _VERSION_CACHE[key] = (time.monotonic(), snap) + +# User-writable DNF snippet dir (panel runs as user `cyberpanel`; cannot rely on /etc). +_CYBERPANEL_DNF_EXTRA = '/home/cyberpanel/.cyberpanel-dnf/repos.d' + + +def _version_tuple(ver): + """Numeric tuple for semver-style compare; empty if not usable.""" + if ver is None: + return () + s = str(ver).strip() + if not s or s.lower() == 'latest': + return () + parts = [] + for x in re.findall(r'\d+', s): + try: + parts.append(int(x)) + except ValueError: + break + return tuple(parts) + + +def version_compare(a, b): + """ + Compare two version strings: return -1 if a < b, 0 if equal or incomparable, 1 if a > b. + """ + ta = _version_tuple(a) + tb = _version_tuple(b) + if not ta or not tb: + return 0 + length = max(len(ta), len(tb)) + for i in range(length): + x = ta[i] if i < len(ta) else 0 + y = tb[i] if i < len(tb) else 0 + if x < y: + return -1 + if x > y: + return 1 + return 0 + + +def _max_version_string(candidates): + best = '' + for v in candidates or []: + if not v: + continue + if not best or version_compare(best, v) < 0: + best = v + return best + + +def _run(cmd, timeout=120): + try: + res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return res.returncode, (res.stdout or ''), (res.stderr or '') + except Exception as err: + return 1, '', str(err) + + +def normalize_elasticsearch_major(es_major): + """Supported Elasticsearch package streams (official artifacts.elastic.co).""" + m = str(es_major).strip() + if m in ('7', '8', '9'): + return m + return '8' + + +def _ensure_cyberpanel_es_repo(es_major): + """Elasticsearch official repo for version discovery (no root; gpg off for repoquery-only).""" + major = normalize_elasticsearch_major(es_major) + try: + os.makedirs(_CYBERPANEL_DNF_EXTRA, mode=0o755, exist_ok=True) + except Exception: + return + path = os.path.join( + _CYBERPANEL_DNF_EXTRA, 'cyberpanel-elasticsearch-{0}.repo'.format(major) + ) + content = ( + '[cyberpanel-elasticsearch-{0}]\n' + 'name=Elasticsearch {0}.x metadata (CyberPanel)\n' + 'baseurl=https://artifacts.elastic.co/packages/{0}.x/yum\n' + 'gpgcheck=0\n' + 'repo_gpgcheck=0\n' + 'enabled=1\n' + ).format(major) + try: + with open(path, 'w') as handle: + handle.write(content) + os.chmod(path, 0o644) + except Exception: + pass + + +def _normalize_versions(raw_versions, max_items=25): + versions = [] + seen = set() + for item in raw_versions: + value = (item or '').strip() + if not value or value in seen: + continue + seen.add(value) + versions.append(value) + return versions[:max_items] + + +def _sort_versions_desc(candidates): + def key_fn(ver): + nums = [int(x) for x in re.findall(r'\d+', ver) if x.isdigit()] + return nums or [0] + + try: + return sorted(set(candidates), key=key_fn, reverse=True) + except Exception: + return sorted(set(candidates), reverse=True) + + +def _dnf_reposdir_flag(use_cyberpanel_extra): + if not use_cyberpanel_extra: + return [] + if not os.path.isdir(_CYBERPANEL_DNF_EXTRA): + try: + os.makedirs(_CYBERPANEL_DNF_EXTRA, mode=0o755, exist_ok=True) + except Exception: + return [] + return ['--setopt=reposdir=/etc/yum.repos.d,{0}'.format(_CYBERPANEL_DNF_EXTRA)] + + +def _rhel_repoquery_versions( + pkg_name, + use_cyberpanel_extra_repos=False, + enablerepos=None, + latest_limit=50, + normalize_max=25, +): + """ + Resolve distinct %{version} strings from enabled repos. + RPM NEVRA text parsing is brittle (el9_7 etc.); repoquery --qf is reliable. + + For RabbitMQ, pass latest_limit=None (no cap — el8-tagged RPMs may share metadata + with EL9) and normalize_max=200 so stream filtering (3.x vs 4.x) is not fed only + the newest majors (which would hide the other line entirely). + """ + dnf_cmd = ( + ['dnf'] + + _dnf_reposdir_flag(use_cyberpanel_extra_repos) + + [ + 'repoquery', + '--available', + '--show-duplicates', + ] + ) + if latest_limit is not None: + dnf_cmd.append('--latest-limit={0}'.format(int(latest_limit))) + dnf_cmd.extend(['--qf', '%{version}', pkg_name]) + if enablerepos: + for repo_id in enablerepos: + dnf_cmd.extend(['--enablerepo', repo_id]) + rc, out, err = _run(dnf_cmd, timeout=240) + raw = [] + if rc == 0 and out.strip(): + for line in out.splitlines(): + v = (line or '').strip() + if v and re.match(r'^[0-9]', v): + raw.append(v) + if raw: + return _normalize_versions(_sort_versions_desc(raw), max_items=normalize_max) + + # Legacy systems / fallback + yum_cmd = ['yum', 'repoquery', '--available', '--show-duplicates'] + if latest_limit is not None: + yum_cmd.append('--latest-limit={0}'.format(int(latest_limit))) + yum_cmd.extend(['--qf', '%{version}', pkg_name]) + rc2, out2, _ = _run(yum_cmd, timeout=120) + raw2 = [] + if rc2 == 0 and out2.strip(): + for line in out2.splitlines(): + v = (line or '').strip() + if v and re.match(r'^[0-9]', v): + raw2.append(v) + if raw2: + return _normalize_versions(_sort_versions_desc(raw2), max_items=normalize_max) + + # Oldest fallback: yum list + rc3, out3, _ = _run(['yum', '--showduplicates', 'list', pkg_name], timeout=120) + raw3 = [] + if rc3 == 0: + for line in out3.splitlines(): + row = line.strip() + if not row or row.startswith('Loaded plugins') or row.startswith('Available'): + continue + fields = row.split() + if len(fields) >= 2 and pkg_name in fields[0]: + raw3.append(fields[1]) + if raw3: + return _normalize_versions(_sort_versions_desc(raw3), max_items=normalize_max) + return [] + + +def _merge_version_candidates(primary, extra, normalize_max=200): + """Dedupe and sort descending for RabbitMQ multi-source repoquery.""" + return _normalize_versions( + _sort_versions_desc(list(primary or []) + list(extra or [])), + max_items=normalize_max, + ) + + +def _rhel_repoquery_rabbitmq_packagecloud_el_dist(pkg_name, el_major): + """ + Query rabbitmq-server versions from a specific Packagecloud el/N path without + enabling that repo system-wide. Helps when el/9 metadata lags el/8 for 4.x. + """ + arch = platform.machine() or 'x86_64' + repoid = 'cybercp-pc-rmq-el{0}'.format(int(el_major)) + base = 'https://packagecloud.io/rabbitmq/rabbitmq-server/el/{0}/{1}'.format( + int(el_major), arch + ) + cmd = [ + 'dnf', + 'repoquery', + '--repofrompath={0},{1}'.format(repoid, base), + '--setopt={0}.gpgcheck=0'.format(repoid), + '--setopt={0}.repo_gpgcheck=0'.format(repoid), + '--available', + '--show-duplicates', + '--qf', + '%{version}', + pkg_name, + ] + rc, out, _ = _run(cmd, timeout=240) + if rc != 0 or not (out or '').strip(): + return [] + raw = [] + for line in out.splitlines(): + v = (line or '').strip() + if v and re.match(r'^[0-9]', v): + raw.append(v) + return _normalize_versions(_sort_versions_desc(raw), max_items=200) + + +def _debian_versions(pkg_name, normalize_max=25): + versions = [] + _run(['apt-get', 'update', '-y'], timeout=180) + rc, out, _ = _run(['apt-cache', 'madison', pkg_name], timeout=60) + if rc != 0: + return [] + for line in out.splitlines(): + if '|' not in line: + continue + parts = [p.strip() for p in line.split('|')] + if len(parts) >= 2 and parts[1]: + versions.append(parts[1]) + collected = [] + for v in versions: + m = re.search(r'(\d+\.\d+\.\d+)', v) + collected.append(m.group(1) if m else v) + return _normalize_versions(_sort_versions_desc(collected), max_items=normalize_max) + + +def _filter_es_major(versions, es_major): + major = normalize_elasticsearch_major(es_major) + out = [] + for v in versions or []: + head = (v.split('.') or [''])[0] + if head == major: + out.append(v) + return out + + +def _get_available_versions_uncached(app_name, es_major='8', rabbitmq_stream='4'): + pkg_name = package_name_for_app(app_name) + if app_name == 'Elasticsearch': + pkg_name = 'elasticsearch' + + if not pkg_name: + return [] + + rmq_stream = '4' + if app_name == 'RabbitMQ': + from manageServices.application_rabbitmq_repo import ( + normalize_rabbitmq_stream, + ensure_rabbitmq_team_repos, + ) + rmq_stream = normalize_rabbitmq_stream(rabbitmq_stream) + ensure_rabbitmq_team_repos(rmq_stream) + + if is_debian_family(): + if app_name == 'RabbitMQ': + versions = _debian_versions(pkg_name, normalize_max=200) + else: + versions = _debian_versions(pkg_name) + if app_name == 'Elasticsearch': + versions = _filter_es_major(versions, es_major) + else: + if app_name == 'Elasticsearch': + _ensure_cyberpanel_es_repo(es_major) + versions = _rhel_repoquery_versions( + pkg_name, use_cyberpanel_extra_repos=True + ) + versions = _filter_es_major(versions, es_major) + elif app_name == 'RabbitMQ': + versions = _rhel_repoquery_versions( + pkg_name, latest_limit=None, normalize_max=200 + ) + host_major = rhel_major_from_os_release() + # el/9 (and newer) enabled repos often omit 4.x in metadata; el/8 tree may list them. + if host_major is not None and host_major >= 9: + pc_el8 = _rhel_repoquery_rabbitmq_packagecloud_el_dist(pkg_name, 8) + if pc_el8: + versions = _merge_version_candidates(versions, pc_el8, 200) + else: + versions = _rhel_repoquery_versions(pkg_name) + + if app_name == 'RabbitMQ': + from manageServices.application_rabbitmq_repo import ( + RABBITMQ_4X_METADATA_FALLBACK_VERSIONS, + filter_versions_for_stream, + refresh_debian_apt_metadata, + refresh_rhel_metadata_for_rabbitmq_repos, + ) + versions = filter_versions_for_stream(versions, rmq_stream) + if not versions: + if is_debian_family(): + refresh_debian_apt_metadata() + versions = _debian_versions(pkg_name, normalize_max=200) + else: + refresh_rhel_metadata_for_rabbitmq_repos() + versions = _rhel_repoquery_versions( + pkg_name, latest_limit=None, normalize_max=200 + ) + host_major = rhel_major_from_os_release() + if host_major is not None and host_major >= 9: + pc_el8 = _rhel_repoquery_rabbitmq_packagecloud_el_dist(pkg_name, 8) + if pc_el8: + versions = _merge_version_candidates(versions, pc_el8, 200) + versions = filter_versions_for_stream(versions, rmq_stream) + # Always offer GA 4.x when DNF lists none (panel user may get empty repoquery). + if rmq_stream == '4' and not versions and not is_debian_family(): + versions = list(RABBITMQ_4X_METADATA_FALLBACK_VERSIONS) + versions = _normalize_versions(_sort_versions_desc(versions), max_items=40) + return versions + + +def get_available_versions(app_name, es_major='8', rabbitmq_stream='4'): + """ + Cached wrapper: avoids hammering DNF from many concurrent panel workers (503 on Manage Applications). + """ + key = _version_cache_key(app_name, es_major, rabbitmq_stream) + hit = _cache_get_versions(key) + if hit is not None: + return hit + + with _DNF_COLD_FETCH_LOCK: + hit2 = _cache_get_versions(key) + if hit2 is not None: + return hit2 + versions = _get_available_versions_uncached( + app_name, es_major, rabbitmq_stream + ) + if versions: + _cache_put_versions(key, versions) + return list(versions) + + +def get_latest_version(app_name, es_major='8', rabbitmq_stream='4'): + versions = get_available_versions(app_name, es_major, rabbitmq_stream) + if not versions: + return '' + return versions[0] + + +def get_branch_and_global_latest(app_name, es_major='8', rabbitmq_stream='4'): + """ + Latest on the UI-selected branch/stream vs latest across all supported branches. + + Returns (latest_on_branch, latest_global). + """ + latest_branch = get_latest_version(app_name, es_major, rabbitmq_stream) + if app_name == 'Elasticsearch': + candidates = [] + for m in ('7', '8', '9'): + v = get_latest_version('Elasticsearch', m, rabbitmq_stream) + if v: + candidates.append(v) + latest_global = _max_version_string(candidates) if candidates else '' + elif app_name == 'RabbitMQ': + candidates = [] + for s in ('3', '4'): + v = get_latest_version('RabbitMQ', es_major, s) + if v: + candidates.append(v) + latest_global = _max_version_string(candidates) if candidates else '' + else: + latest_global = latest_branch + if not latest_global: + latest_global = latest_branch + return latest_branch, latest_global + + +def cross_branch_newer_suggested(installed, latest_branch, latest_global): + """ + True when installed is current (or ahead of) the selected branch latest but + a newer release exists on another line (e.g. 8.x latest installed, 9.x exists). + """ + if not installed or not latest_global: + return False + if version_compare(installed, latest_global) >= 0: + return False + if not latest_branch: + return True + return version_compare(installed, latest_branch) >= 0 diff --git a/manageServices/serviceManager.py b/manageServices/serviceManager.py index 0476b3b02..a08b3e75b 100644 --- a/manageServices/serviceManager.py +++ b/manageServices/serviceManager.py @@ -1,6 +1,10 @@ +import os import os.path import sys import django +repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +if repo_root not in sys.path: + sys.path.append(repo_root) sys.path.append('/usr/local/CyberCP') os.environ.setdefault("DJANGO_SETTINGS_MODULE", "CyberCP.settings") django.setup() @@ -12,6 +16,10 @@ import argparse from serverStatus.serverStatusUtil import ServerStatusUtil from plogical import CyberCPLogFileWriter as logging import subprocess +from manageServices.application_detection import managed_apps_os_support +from manageServices import application_elasticsearch +from manageServices import application_redis +from manageServices import application_rabbitmq class ServiceManager: @@ -142,184 +150,114 @@ autosecondary=yes Supermasters(ip=self.extraArgs['masterServerIP'], nameserver=self.extraArgs['slaveServerNS'], account='').save() @staticmethod - def InstallElasticSearch(): + def InstallElasticSearch(version='latest', esMajor='8'): + return application_elasticsearch.install(version=version, es_major=esMajor) - statusFile = open(ServerStatusUtil.lswsInstallStatusPath, 'w') - - if ProcessUtilities.decideDistro() == ProcessUtilities.centos or ProcessUtilities.decideDistro() == ProcessUtilities.cent8: - command = 'rpm --import https://artifacts.elastic.co/GPG-KEY-elasticsearch' - ServerStatusUtil.executioner(command, statusFile) - - repoPath = '/etc/yum.repos.d/elasticsearch.repo' - - content = ''' -[elasticsearch] -name=Elasticsearch repository for 7.x packages -baseurl=https://artifacts.elastic.co/packages/7.x/yum -gpgcheck=1 -gpgkey=https://artifacts.elastic.co/GPG-KEY-elasticsearch -enabled=0 -autorefresh=1 -type=rpm-md -''' - - writeToFile = open(repoPath, 'w') - writeToFile.write(content) - writeToFile.close() - - command = 'yum install --enablerepo=elasticsearch elasticsearch -y' - ServerStatusUtil.executioner(command, statusFile) - else: - command = 'wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo apt-key add -' - subprocess.call(command, shell=True) - - command = 'apt-get install apt-transport-https -y' - ServerStatusUtil.executioner(command, statusFile) - - command = 'echo "deb https://artifacts.elastic.co/packages/7.x/apt stable main" | sudo tee /etc/apt/sources.list.d/elastic-7.x.list' - subprocess.call(command, shell=True) - - command = 'apt-get update -y' - ServerStatusUtil.executioner(command, statusFile) - - command = 'apt-get install elasticsearch -y' - ServerStatusUtil.executioner(command, statusFile) - - ### Tmp folder configurations - - command = 'mkdir -p /home/elasticsearch/tmp' - ServerStatusUtil.executioner(command, statusFile) - - command = 'chown elasticsearch:elasticsearch /home/elasticsearch/tmp' - ServerStatusUtil.executioner(command, statusFile) - - jvmOptions = '/etc/elasticsearch/jvm.options' - - writeToFile = open(jvmOptions, 'a') - writeToFile.write('-Djava.io.tmpdir=/home/elasticsearch/tmp\n') - writeToFile.close() - - command = 'systemctl enable elasticsearch' - ServerStatusUtil.executioner(command, statusFile) - - command = 'systemctl start elasticsearch' - ServerStatusUtil.executioner(command, statusFile) - - command = 'touch /home/cyberpanel/elasticsearch' - ServerStatusUtil.executioner(command, statusFile) - - - - logging.CyberCPLogFileWriter.statusWriter(ServerStatusUtil.lswsInstallStatusPath, - "Packages successfully installed.[200]\n", 1) - return 0 + @staticmethod + def UpgradeElasticSearch(version='latest', esMajor='8'): + return application_elasticsearch.upgrade(version=version, es_major=esMajor) @staticmethod def RemoveElasticSearch(): - - statusFile = open(ServerStatusUtil.lswsInstallStatusPath, 'w') - - if ProcessUtilities.decideDistro() == ProcessUtilities.centos or ProcessUtilities.decideDistro() == ProcessUtilities.cent8: - command = 'rpm --import https://artifacts.elastic.co/GPG-KEY-elasticsearch' - ServerStatusUtil.executioner(command, statusFile) - - repoPath = '/etc/yum.repos.d/elasticsearch.repo' - - try: - os.remove(repoPath) - except: - pass - - command = 'yum erase elasticsearch -y' - ServerStatusUtil.executioner(command, statusFile) - else: - - try: - os.remove('/etc/apt/sources.list.d/elastic-7.x.list') - except: - pass - - - command = 'apt-get remove elasticsearch -y' - ServerStatusUtil.executioner(command, statusFile) - - ### Tmp folder configurations - - command = 'rm -rf /home/elasticsearch/tmp' - ServerStatusUtil.executioner(command, statusFile) - - - command = 'rm -f /home/cyberpanel/elasticsearch' - ServerStatusUtil.executioner(command, statusFile) - - logging.CyberCPLogFileWriter.statusWriter(ServerStatusUtil.lswsInstallStatusPath, - "ElasticSearch successfully removed.[200]\n", 1) - return 0 + return application_elasticsearch.remove() @staticmethod - def InstallRedis(): + def InstallRedis(version='latest'): + return application_redis.install(version=version) - statusFile = open(ServerStatusUtil.lswsInstallStatusPath, 'w') - - if ProcessUtilities.decideDistro() == ProcessUtilities.centos or ProcessUtilities.decideDistro() == ProcessUtilities.cent8: - command = 'yum install redis -y' - ServerStatusUtil.executioner(command, statusFile) - else: - - command = 'DEBIAN_FRONTEND=noninteractive apt-get install redis-server -y' - ServerStatusUtil.executioner(command, statusFile) - - - command = 'systemctl enable redis' - ServerStatusUtil.executioner(command, statusFile) - - command = 'systemctl start redis' - ServerStatusUtil.executioner(command, statusFile) - - command = 'touch /home/cyberpanel/redis' - ServerStatusUtil.executioner(command, statusFile) - - logging.CyberCPLogFileWriter.statusWriter(ServerStatusUtil.lswsInstallStatusPath, - "Redis successfully installed.[200]\n", 1) - return 0 + @staticmethod + def UpgradeRedis(version='latest'): + return application_redis.upgrade(version=version) @staticmethod def RemoveRedis(): + return application_redis.remove() - statusFile = open(ServerStatusUtil.lswsInstallStatusPath, 'w') + @staticmethod + def InstallRabbitMQ(version='latest', stream='3'): + return application_rabbitmq.install(version=version, stream=stream) - if ProcessUtilities.decideDistro() == ProcessUtilities.centos or ProcessUtilities.decideDistro() == ProcessUtilities.cent8: - command = 'yum erase redis -y' - ServerStatusUtil.executioner(command, statusFile) - else: + @staticmethod + def UpgradeRabbitMQ(version='latest', stream='3'): + return application_rabbitmq.upgrade(version=version, stream=stream) - command = 'apt-get remove redis-server -y' - ServerStatusUtil.executioner(command, statusFile) - - - command = 'rm -f /home/cyberpanel/redis' - ServerStatusUtil.executioner(command, statusFile) - - logging.CyberCPLogFileWriter.statusWriter(ServerStatusUtil.lswsInstallStatusPath, - "Redis successfully removed.[200]\n", 1) - return 0 + @staticmethod + def RemoveRabbitMQ(): + return application_rabbitmq.remove() def main(): parser = argparse.ArgumentParser(description='CyberPanel Application Manager') parser.add_argument('--function', help='Function') - + parser.add_argument('--app', help='Application name') + parser.add_argument('--action', help='Action to run: install|remove|upgrade') + parser.add_argument('--version', default='latest', help='Target package version or latest') + parser.add_argument('--esMajor', default='8', help='Elasticsearch major stream (7|8|9)') + parser.add_argument('--rabbitmqStream', default='4', help='RabbitMQ major stream (3|4)') args = vars(parser.parse_args()) + support = managed_apps_os_support() + if not support['supported']: + logging.CyberCPLogFileWriter.statusWriter( + ServerStatusUtil.lswsInstallStatusPath, + support['reason'] + '\n', + 1 + ) + return + if args["function"] == "InstallElasticSearch": - ServiceManager.InstallElasticSearch() + ServiceManager.InstallElasticSearch(version=args.get('version', 'latest'), esMajor=args.get('esMajor', '8')) + elif args["function"] == "UpgradeElasticSearch": + ServiceManager.UpgradeElasticSearch(version=args.get('version', 'latest'), esMajor=args.get('esMajor', '8')) elif args["function"] == "RemoveElasticSearch": ServiceManager.RemoveElasticSearch() elif args["function"] == "InstallRedis": - ServiceManager.InstallRedis() + ServiceManager.InstallRedis(version=args.get('version', 'latest')) + elif args["function"] == "UpgradeRedis": + ServiceManager.UpgradeRedis(version=args.get('version', 'latest')) elif args["function"] == "RemoveRedis": ServiceManager.RemoveRedis() + elif args["function"] == "InstallRabbitMQ": + ServiceManager.InstallRabbitMQ( + version=args.get('version', 'latest'), + stream=args.get('rabbitmqStream', '4'), + ) + elif args["function"] == "UpgradeRabbitMQ": + ServiceManager.UpgradeRabbitMQ( + version=args.get('version', 'latest'), + stream=args.get('rabbitmqStream', '4'), + ) + elif args["function"] == "RemoveRabbitMQ": + ServiceManager.RemoveRabbitMQ() + elif args.get("app") and args.get("action"): + app_name = args.get("app") + action = args.get("action").lower() + version = args.get("version", "latest") + es_major = args.get("esMajor", "8") + rmq_stream = args.get("rabbitmqStream", "4") + + if app_name == 'Elasticsearch': + if action == 'install': + ServiceManager.InstallElasticSearch(version=version, esMajor=es_major) + elif action == 'upgrade': + ServiceManager.UpgradeElasticSearch(version=version, esMajor=es_major) + elif action == 'remove': + ServiceManager.RemoveElasticSearch() + elif app_name == 'Redis': + if action == 'install': + ServiceManager.InstallRedis(version=version) + elif action == 'upgrade': + ServiceManager.UpgradeRedis(version=version) + elif action == 'remove': + ServiceManager.RemoveRedis() + elif app_name == 'RabbitMQ': + if action == 'install': + ServiceManager.InstallRabbitMQ(version=version, stream=rmq_stream) + elif action == 'upgrade': + ServiceManager.UpgradeRabbitMQ(version=version, stream=rmq_stream) + elif action == 'remove': + ServiceManager.RemoveRabbitMQ() diff --git a/manageServices/static/manageServices/images/rabbitmq-logo.svg b/manageServices/static/manageServices/images/rabbitmq-logo.svg new file mode 100644 index 000000000..178ae8bfc --- /dev/null +++ b/manageServices/static/manageServices/images/rabbitmq-logo.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + diff --git a/manageServices/static/manageServices/images/rabbitmq.png b/manageServices/static/manageServices/images/rabbitmq.png new file mode 100644 index 000000000..41a120e6f Binary files /dev/null and b/manageServices/static/manageServices/images/rabbitmq.png differ diff --git a/manageServices/static/manageServices/images/rabbitmq.svg b/manageServices/static/manageServices/images/rabbitmq.svg new file mode 100644 index 000000000..42d70d2b3 --- /dev/null +++ b/manageServices/static/manageServices/images/rabbitmq.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/manageServices/static/manageServices/manageServices.js b/manageServices/static/manageServices/manageServices.js index 949fab41c..c3f419f2c 100644 --- a/manageServices/static/manageServices/manageServices.js +++ b/manageServices/static/manageServices/manageServices.js @@ -428,10 +428,505 @@ app.controller('pureFTPD', function ($scope, $http, $timeout, $window) { app.controller('manageApplications', function ($scope, $http, $timeout, $window) { + /** + * Normalize entries from applicationMeta (strings, numbers, or rare object shapes) + * so version pickers never show blank rows. CyberPanel uses {$ ... $} interpolation + * in templates; list labels use versionLabel() for consistent display. + */ + function normalizeVersionToken(v) { + if (v === null || v === undefined) { + return ''; + } + if (v === 'latest') { + return 'latest'; + } + if (typeof v === 'number' && isFinite(v)) { + return String(v); + } + if (angular.isObject(v)) { + var o = v; + var cand = o.version || o.Version || o.value || o.name || o.ver || o.label; + if (cand !== undefined && cand !== null) { + return String(cand).trim(); + } + try { + return JSON.stringify(o); + } catch (ignore) { + return ''; + } + } + return String(v).trim(); + } + + function sanitizeVersionsArray(vers) { + if (!angular.isArray(vers)) { + return []; + } + var out = []; + var seen = {}; + vers.forEach(function (raw) { + var t = normalizeVersionToken(raw); + if (!t || t === 'latest') { + return; + } + if (!seen[t]) { + seen[t] = true; + out.push(t); + } + }); + return out; + } + + function versionMatchesRabbitmqStream(ver, stream) { + var s = String(stream || '4').trim(); + var t = normalizeVersionToken(ver); + if (!t || t === 'latest') { + return false; + } + var m = /^(\d+)\./.exec(t); + return !!(m && m[1] === s); + } + + function versionMatchesEsMajor(ver, major) { + var mjr = String(major || '8').trim(); + var t = normalizeVersionToken(ver); + if (!t || t === 'latest') { + return false; + } + var m = /^(\d+)\./.exec(t); + return !!(m && m[1] === mjr); + } + + $scope.versionLabel = function (v) { + if (v === 'latest') { + return 'latest'; + } + var t = normalizeVersionToken(v); + return t || '(unknown)'; + }; + + $scope.versionTrackId = function (idx, v) { + return String(idx) + '|' + $scope.versionLabel(v); + }; + + /* false = long-running install/remove/poll (show spinners); true = idle */ $scope.cyberpanelLoading = true; + /** Background applicationMeta refresh — separate from cyberpanelLoading so the page does not “freeze” on every modal open. */ + $scope.appsMetaRefreshing = false; + $scope.apps = [ + {name: 'Elasticsearch', image: '/static/manageServices/images/elastic-search.png'}, + {name: 'Redis', image: '/static/manageServices/images/redis.png'}, + {name: 'RabbitMQ', image: '/static/manageServices/images/rabbitmq-logo.svg'} + ]; - $scope.removeInstall = function (appName, status) { + (function mergeMetaBootstrap() { + var el = document.getElementById('manageApplicationsMetaBootstrap'); + if (!el || !el.textContent) { + return; + } + var raw = el.textContent.trim(); + if (!raw) { + return; + } + try { + var boot = JSON.parse(raw); + if (!boot || Number(boot.status) !== 1) { + return; + } + var appMap = {}; + (boot.apps || []).forEach(function (a) { + if (a && a.name) { + appMap[a.name] = a; + } + }); + $scope.apps = $scope.apps.map(function (baseApp) { + var meta = appMap[baseApp.name] || {}; + var vers = meta.versions; + if (!angular.isArray(vers)) { + vers = []; + } + vers = sanitizeVersionsArray(vers); + return { + name: baseApp.name, + image: baseApp.image, + installed: !!meta.installed, + installedVersion: meta.installedVersion || '', + updateAvailable: !!meta.updateAvailable, + crossBranchUpdateSuggested: !!meta.crossBranchUpdateSuggested, + versions: vers, + latestAvailable: meta.latestAvailable || '', + latestOverall: meta.latestOverall || '', + rabbitmqVersionsHint: meta.rabbitmqVersionsHint || '' + }; + }); + } catch (ignore) { + /* keep bare apps list */ + } + })(); + $scope.selectedVersion = 'latest'; + /** Row highlight uses $index so only one row looks selected (avoids sticky :focus / repeater quirks). */ + $scope.selectedVersionRowIndex = 0; + $scope.selectedVersions = ['latest']; + + $scope.recalcSelectedVersionRowIndex = function () { + var list = $scope.selectedVersions || []; + var sel = $scope.selectedVersion; + var i = list.indexOf(sel); + if (i < 0) { + var normSel = normalizeVersionToken(sel); + if (normSel) { + for (var j = 0; j < list.length; j += 1) { + if (normalizeVersionToken(list[j]) === normSel) { + i = j; + $scope.selectedVersion = list[j]; + break; + } + } + } + } + $scope.selectedVersionRowIndex = (i >= 0) ? i : 0; + }; + + $scope.selectManagedAppVersion = function (idx, v, $event) { + var n = (typeof idx === 'number') ? idx : parseInt(idx, 10); + if (!isFinite(n) || n < 0) { + n = 0; + } + $scope.selectedVersionRowIndex = n; + $scope.selectedVersion = v; + if ($event && $event.target && typeof $event.target.blur === 'function') { + $event.target.blur(); + } else if (typeof document !== 'undefined' && document.activeElement && typeof document.activeElement.blur === 'function') { + document.activeElement.blur(); + } + }; + $scope.selectedEsMajor = '8'; + $scope.selectedRabbitmqStream = '4'; + /** RabbitMQ: 4.x is default for new installs (metadata prefetched). Upgrade may require picking stream if version line is unknown. ES major still user-picked before version list loads. */ + $scope.rabbitmqBranchChosen = false; + $scope.esMajorChosen = false; + $scope.confirmAction = false; + $scope.selectedCurrentVersion = ''; + + $scope.chooseRabbitmqStream = function (stream) { + var s = String(stream || '4').trim(); + if (s !== '3' && s !== '4') { + s = '4'; + } + $scope.selectedRabbitmqStream = s; + $scope.rabbitmqBranchChosen = true; + $scope.refreshMeta(); + }; + + $scope.chooseEsMajor = function (major) { + var m = String(major || '8').trim(); + if (m !== '7' && m !== '8' && m !== '9') { + m = '8'; + } + $scope.selectedEsMajor = m; + $scope.esMajorChosen = true; + $scope.refreshMeta(); + }; + + /** + * When the install/upgrade modal is open, re-apply version list from latest applicationMeta. + * (Page-load meta can be empty for ES if dnf was slow; opening the modal must refetch.) + */ + $scope.syncModalVersionLists = function () { + if (!$scope.appName || ($scope.status !== 'Installing' && $scope.status !== 'Upgrading')) { + return; + } + if ($scope.appName !== 'Elasticsearch' && $scope.appName !== 'Redis' && $scope.appName !== 'RabbitMQ') { + return; + } + if ($scope.appName === 'RabbitMQ' && !$scope.rabbitmqBranchChosen) { + $scope.selectedVersions = ['latest']; + $scope.selectedVersion = 'latest'; + $scope.repoShowsOnlyOneStream = false; + $scope.recalcSelectedVersionRowIndex(); + return; + } + if ($scope.appName === 'Elasticsearch' && !$scope.esMajorChosen) { + $scope.selectedVersions = ['latest']; + $scope.selectedVersion = 'latest'; + $scope.repoShowsOnlyOneStream = false; + $scope.recalcSelectedVersionRowIndex(); + return; + } + var meta = $scope.findAppMeta($scope.appName); + var vers = sanitizeVersionsArray((meta && meta.versions) ? meta.versions : []); + $scope.selectedVersions = ['latest'].concat(vers); + var curRaw = (meta && meta.installedVersion) ? meta.installedVersion : ($scope.selectedCurrentVersion || ''); + var cur = normalizeVersionToken(curRaw) || String(curRaw || '').trim(); + if (cur && $scope.selectedVersions.indexOf(cur) === -1) { + var allowCur = true; + if ($scope.appName === 'RabbitMQ') { + allowCur = versionMatchesRabbitmqStream(cur, $scope.selectedRabbitmqStream); + } else if ($scope.appName === 'Elasticsearch') { + allowCur = versionMatchesEsMajor(cur, $scope.selectedEsMajor); + } + if (allowCur) { + $scope.selectedVersions.push(cur); + } + } + if (cur) { + $scope.selectedCurrentVersion = cur; + } + var prevSel = $scope.selectedVersion; + if (prevSel && $scope.selectedVersions.indexOf(prevSel) !== -1) { + $scope.selectedVersion = prevSel; + } else { + $scope.selectedVersion = 'latest'; + } + var realVers = ($scope.selectedVersions || []).filter(function (v) { + return v && v !== 'latest'; + }); + $scope.repoShowsOnlyOneStream = ($scope.status === 'Upgrading' && realVers.length <= 1); + $scope.recalcSelectedVersionRowIndex(); + }; + + $scope.refreshMeta = function () { + $scope.appsMetaRefreshing = true; + var url = "/manageservices/applicationMeta"; + var data = { + esMajor: $scope.selectedEsMajor, + rabbitmqStream: $scope.selectedRabbitmqStream + }; + var config = { + headers: { + 'Content-Type': 'application/json;charset=UTF-8', + 'X-CSRFToken': getCookie('csrftoken') + }, + transformRequest: function (payload) { + return angular.toJson(payload); + } + }; + + return $http.post(url, data, config).then(function (response) { + $scope.appsMetaRefreshing = false; + var payload = response.data; + var ok = payload && (payload.status === 1 || payload.status === '1'); + if (ok) { + var appMap = {}; + (payload.apps || []).forEach(function (app) { + appMap[app.name] = app; + }); + var esMetaResp = appMap['Elasticsearch']; + var rmqMetaResp = appMap['RabbitMQ']; + var respEsMaj = String(esMetaResp && esMetaResp.major != null ? esMetaResp.major : '').trim(); + if (respEsMaj && respEsMaj !== String($scope.selectedEsMajor || '8').trim()) { + return; + } + var respRmqStream = String(rmqMetaResp && rmqMetaResp.rabbitmqStream != null ? rmqMetaResp.rabbitmqStream : '').trim(); + if (respRmqStream && respRmqStream !== String($scope.selectedRabbitmqStream || '4').trim()) { + return; + } + $scope.apps = $scope.apps.map(function (baseApp) { + var meta = appMap[baseApp.name] || {}; + var vers = meta.versions; + if (!angular.isArray(vers)) { + vers = []; + } + vers = sanitizeVersionsArray(vers); + return { + name: baseApp.name, + image: baseApp.image, + installed: !!meta.installed, + installedVersion: meta.installedVersion || '', + updateAvailable: !!meta.updateAvailable, + crossBranchUpdateSuggested: !!meta.crossBranchUpdateSuggested, + versions: vers, + latestAvailable: meta.latestAvailable || '', + latestOverall: meta.latestOverall || '', + rabbitmqVersionsHint: meta.rabbitmqVersionsHint || '' + }; + }); + $scope.syncModalVersionLists(); + } else { + new PNotify({ + title: 'Operation Failed!', + text: (payload && (payload.error_message || payload.errorMessage)) || 'Could not load application metadata.', + type: 'error' + }); + } + }, function () { + $scope.appsMetaRefreshing = false; + new PNotify({ + title: 'Operation Failed!', + text: 'Could not connect to server, please refresh this page', + type: 'error' + }); + }); + }; + + $scope.prepareAction = function (service, status, bootstrapInstalledVersion) { + if (bootstrapInstalledVersion === undefined || bootstrapInstalledVersion === null) { + bootstrapInstalledVersion = ''; + } else { + bootstrapInstalledVersion = String(bootstrapInstalledVersion).trim(); + } + $scope.status = status; + $scope.appName = service.name; + $scope.confirmAction = false; + var effectiveInstalled = (service.installedVersion || bootstrapInstalledVersion || '').trim(); + $scope.selectedCurrentVersion = effectiveInstalled; + + if (service.name === 'RabbitMQ') { + if (effectiveInstalled && /^4\./.test(effectiveInstalled)) { + $scope.selectedRabbitmqStream = '4'; + } else if (effectiveInstalled && /^3\./.test(effectiveInstalled)) { + $scope.selectedRabbitmqStream = '3'; + } else if (status === 'Installing') { + $scope.selectedRabbitmqStream = '4'; + } + if (status === 'Upgrading' && effectiveInstalled) { + if (/^4\./.test(effectiveInstalled) || /^3\./.test(effectiveInstalled)) { + $scope.rabbitmqBranchChosen = true; + } + } + } + + if (service.name === 'Elasticsearch' && effectiveInstalled) { + var iv = effectiveInstalled; + if (/^9\./.test(iv)) { + $scope.selectedEsMajor = '9'; + } else if (/^8\./.test(iv)) { + $scope.selectedEsMajor = '8'; + } else if (/^7\./.test(iv)) { + $scope.selectedEsMajor = '7'; + } + } + + $scope.selectedVersions = ['latest']; + // RabbitMQ upgrade: bootstrap meta is often stream 4; stream follows installed line — do not + // reuse service.versions until refreshMeta returns for selectedRabbitmqStream (avoids mismatched list). + var deferVersionList = (service.name === 'RabbitMQ' && (!$scope.rabbitmqBranchChosen || status === 'Upgrading')) + || (service.name === 'Elasticsearch' && !$scope.esMajorChosen); + if (!deferVersionList) { + var svcVers = sanitizeVersionsArray(service.versions || []); + if (svcVers.length > 0) { + $scope.selectedVersions = ['latest'].concat(svcVers); + } + var curPick = normalizeVersionToken(effectiveInstalled) || effectiveInstalled; + if (curPick && $scope.selectedVersions.indexOf(curPick) === -1) { + $scope.selectedVersions.push(curPick); + } + } + $scope.selectedVersion = 'latest'; + $scope.requestData = ''; + + var realVers = ($scope.selectedVersions || []).filter(function (v) { + return v && v !== 'latest'; + }); + if (deferVersionList) { + $scope.repoShowsOnlyOneStream = false; + } else { + $scope.repoShowsOnlyOneStream = ($scope.status === 'Upgrading' && realVers.length <= 1); + } + $scope.recalcSelectedVersionRowIndex(); + }; + + $scope.findAppMeta = function (appName) { + var found = null; + ($scope.apps || []).forEach(function (item) { + if (item.name === appName) { + found = item; + } + }); + return found || {}; + }; + + $scope.prepareActionByName = function (appName, status, bootstrapInstalledVersion) { + var meta = $scope.findAppMeta(appName); + if (!meta.name) { + meta = {name: appName, versions: []}; + } + var mver = meta.versions; + if (!angular.isArray(mver)) { + mver = []; + } + var merged = { + name: meta.name, + image: meta.image, + installed: meta.installed, + installedVersion: meta.installedVersion || '', + versions: mver + }; + $scope.prepareAction(merged, status, bootstrapInstalledVersion); + }; + + /** + * Prepare scope then show modal (do not use data-toggle + ng-click — Bootstrap can + * open the dialog before Angular runs ng-click, leaving status/appName unset). + * For managed apps, wait for applicationMeta so version lists and installedVersion are correct (upgrade vs downgrade). + */ + $scope.openApplicationsModal = function (appName, status, bootstrapInstalledVersion) { + var needMeta = (appName === 'RabbitMQ' || appName === 'Elasticsearch' || appName === 'Redis'); + var showModal = function () { + if (typeof window.jQuery !== 'undefined' && jQuery('#settings').modal) { + jQuery('#settings').modal('show'); + } + }; + if (needMeta) { + if (appName === 'RabbitMQ') { + if (status === 'Installing') { + $scope.selectedRabbitmqStream = '4'; + $scope.rabbitmqBranchChosen = true; + } else { + $scope.rabbitmqBranchChosen = false; + } + } + if (appName === 'Elasticsearch') { + $scope.esMajorChosen = false; + } + // RabbitMQ install: default 4.x stream and prefetch metadata. Upgrade: pick stream from installed version. + // Elasticsearch: still wait for user major. Redis refreshes on open. + $scope.appName = appName; + $scope.status = status; + $scope.prepareActionByName(appName, status, bootstrapInstalledVersion); + showModal(); + if (appName === 'Redis' || (appName === 'RabbitMQ' && $scope.rabbitmqBranchChosen)) { + $timeout(function () { + $scope.refreshMeta(); + }, 0); + } + } else { + $scope.prepareActionByName(appName, status, bootstrapInstalledVersion); + showModal(); + } + }; + + $scope.runAction = function () { + var appName = $scope.appName; + var status = $scope.status; + if (!appName || !status) { + return; + } + if ((status === 'Removing' || status === 'Upgrading') && !$scope.confirmAction) { + new PNotify({ + title: 'Confirmation Required', + text: 'Please confirm this action before proceeding.', + type: 'warning' + }); + return; + } + if (appName === 'RabbitMQ' && (status === 'Installing' || status === 'Upgrading') && !$scope.rabbitmqBranchChosen) { + new PNotify({ + title: 'Stream required', + text: 'Choose RabbitMQ 4.x or 3.x above to load versions for that line.', + type: 'warning' + }); + return; + } + if (appName === 'Elasticsearch' && (status === 'Installing' || status === 'Upgrading') && !$scope.esMajorChosen) { + new PNotify({ + title: 'Major version required', + text: 'Choose Elasticsearch major (7, 8, or 9) above to load versions for that line.', + type: 'warning' + }); + return; + } $scope.status = status; $scope.appName = appName; @@ -441,7 +936,11 @@ app.controller('manageApplications', function ($scope, $http, $timeout, $window) var data = { appName: appName, - status: status + status: status, + version: $scope.selectedVersion || 'latest', + esMajor: $scope.selectedEsMajor || '8', + rabbitmqStream: $scope.selectedRabbitmqStream || '4', + confirmAction: $scope.confirmAction === true }; var config = { @@ -524,6 +1023,18 @@ app.controller('manageApplications', function ($scope, $http, $timeout, $window) } + // Do not fetch package metadata on page load; it can block workers under DNF load. + // Metadata is fetched on-demand when opening install/version-change modals. + + if (typeof window.jQuery !== 'undefined' && jQuery.fn.on) { + jQuery('#settings').on('hidden.bs.modal', function () { + $scope.$evalAsync(function () { + $scope.rabbitmqBranchChosen = false; + $scope.esMajorChosen = false; + }); + }); + } + }); /* Java script code */ \ No newline at end of file diff --git a/manageServices/templates/manageServices/applications.html b/manageServices/templates/manageServices/applications.html index 2420c68e2..cc029e1c8 100644 --- a/manageServices/templates/manageServices/applications.html +++ b/manageServices/templates/manageServices/applications.html @@ -260,8 +260,113 @@ .modal-body { padding: 25px; + /* Theme may set light text globally; native open state is OS-drawn; use a custom list so version text is always readable. */ + .manage-apps-version-picker { + border: 1px solid #94a3b8; + border-radius: 8px; + overflow: hidden; + background: #ffffff; + margin-top: 6px; + } + + .manage-apps-version-current { + padding: 10px 12px; + background: #e2e8f0; + color: #0f172a; + font-size: 14px; + font-weight: 600; + border-bottom: 1px solid #94a3b8; + } + + .manage-apps-version-rows { + max-height: 240px; + overflow-y: auto; + background: #ffffff; + } + + .manage-apps-version-row { + display: block; + width: 100%; + text-align: left; + padding: 10px 14px; + margin: 0; + border: 0; + border-bottom: 1px solid #e2e8f0; + background: #ffffff !important; + color: #0f172a !important; + font-size: 14px; + line-height: 1.35; + cursor: pointer; + -webkit-text-fill-color: #0f172a; + } + + /* Only one row should read as "selected"; non-active rows stay white unless hover/focus. */ + .manage-apps-version-rows button.manage-apps-version-row:not(.is-active) { + background: #ffffff !important; + } + + .manage-apps-version-rows button.manage-apps-version-row:not(.is-active):hover, + .manage-apps-version-rows button.manage-apps-version-row:not(.is-active):focus { + background: #f1f5f9 !important; + color: #0f172a !important; + -webkit-text-fill-color: #0f172a; + outline: none; + } + + .manage-apps-version-rows button.manage-apps-version-row.is-active, + .manage-apps-version-rows button.manage-apps-version-row.is-active:hover, + .manage-apps-version-rows button.manage-apps-version-row.is-active:focus { + background: #c7d2fe !important; + color: #1e1b4b !important; + -webkit-text-fill-color: #1e1b4b; + font-weight: 600; + outline: none; + } + + .manage-apps-version-row:last-child { + border-bottom: 0; + } + + /* Win over theme + .ng-binding readability rules (must stay dark on white row). */ + .applications-container #settings .modal-body .manage-apps-version-row.ng-binding { + color: #0f172a !important; + -webkit-text-fill-color: #0f172a !important; + } + + [data-theme="dark"] .applications-container #settings .modal-body .manage-apps-version-row.ng-binding { + color: #0f172a !important; + -webkit-text-fill-color: #0f172a !important; + } + + .applications-container #settings .modal-body .manage-apps-version-row.ng-binding.is-active { + color: #1e1b4b !important; + -webkit-text-fill-color: #1e1b4b !important; + } + .install-log { background: var(--bg-secondary, #f8f9ff); border: 1px solid var(--border-color, #e8e9ff); @@ -358,6 +463,7 @@
+ @@ -405,31 +526,37 @@ {% trans "A distributed, RESTful search and analytics engine capable of addressing a growing number of use cases." %} {% elif service.name == 'Redis' %} {% trans "An in-memory data structure store, used as a database, cache, and message broker." %} + {% elif service.name == 'RabbitMQ' %} + {% trans "A reliable message broker for asynchronous processing, queueing, and service-to-service communication." %} {% else %} {% trans "Powerful application for your server infrastructure." %} {% endif %}
- {% if service.installed == 'Installed' %} - - {% else %} - - {% endif %} + + + +
{% endfor %} @@ -451,12 +578,101 @@ - - {% endblock %} \ No newline at end of file diff --git a/manageServices/urls.py b/manageServices/urls.py index 2939c10a0..cc1c56fce 100644 --- a/manageServices/urls.py +++ b/manageServices/urls.py @@ -10,5 +10,6 @@ urlpatterns = [ path('saveStatus', views.saveStatus, name='saveStatus'), path('manageApplications', views.manageApplications, name='manageApplications'), + path('applicationMeta', views.applicationMeta, name='applicationMeta'), path('removeInstall', views.removeInstall, name='removeInstall'), ] diff --git a/manageServices/views.py b/manageServices/views.py index 65aeb0fe8..fd6ffbc86 100644 --- a/manageServices/views.py +++ b/manageServices/views.py @@ -5,6 +5,7 @@ import plogical.CyberCPLogFileWriter as logging from loginSystem.views import loadLoginPage import os import json +import shlex from plogical.httpProc import httpProc from plogical.mailUtilities import mailUtilities @@ -12,6 +13,11 @@ from plogical.acl import ACLManager from .models import PDNSStatus, SlaveServers from .serviceManager import ServiceManager from plogical.processUtilities import ProcessUtilities +from .application_detection import managed_apps_os_support +from .application_page_meta import ( + build_manage_applications_page_data, + get_application_meta_response_dict, +) # Create your views here. def managePowerDNS(request): @@ -270,34 +276,57 @@ def saveStatus(request): return HttpResponse(json_data) def manageApplications(request): - services = [] + services, application_meta_bootstrap_json = build_manage_applications_page_data( + '8', '4' + ) - ## ElasticSearch - - esPath = '/home/cyberpanel/elasticsearch' - rPath = '/home/cyberpanel/redis' - - if os.path.exists(esPath): - installed = 'Installed' - else: - installed = 'Not-Installed' - - if os.path.exists(rPath): - rInstalled = 'Installed' - else: - rInstalled = 'Not-Installed' - - elasticSearch = {'image': '/static/manageServices/images/elastic-search.png', 'name': 'Elasticsearch', - 'installed': installed} - redis = {'image': '/static/manageServices/images/redis.png', 'name': 'Redis', - 'installed': rInstalled} - services.append(elasticSearch) - services.append(redis) - - proc = httpProc(request, 'manageServices/applications.html', - {'services': services}, 'admin') + proc = httpProc( + request, + 'manageServices/applications.html', + { + 'services': services, + 'application_meta_bootstrap_json': application_meta_bootstrap_json, + }, + 'admin', + ) return proc.render() + +def applicationMeta(request): + try: + userID = request.session['userID'] + currentACL = ACLManager.loadedACL(userID) + + if currentACL['admin'] != 1: + return ACLManager.loadErrorJson() + + data = {} + if request.method == 'POST': + data = json.loads(request.body) + + requested_major = str(data.get('esMajor', '8')) + if requested_major not in ('7', '8', '9'): + requested_major = '8' + + requested_rmq_stream = str(data.get('rabbitmqStream', '4')).strip() + if requested_rmq_stream not in ('3', '4'): + requested_rmq_stream = '4' + + response_data = get_application_meta_response_dict( + requested_major, requested_rmq_stream + ) + + return HttpResponse( + json.dumps(response_data, ensure_ascii=False), + content_type='application/json; charset=utf-8', + ) + + except BaseException as msg: + return HttpResponse( + json.dumps({'status': 0, 'error_message': str(msg)}, ensure_ascii=False), + content_type='application/json; charset=utf-8', + ) + def removeInstall(request): try: userID = request.session['userID'] @@ -312,17 +341,63 @@ def removeInstall(request): status = data['status'] appName = data['appName'] + version = str(data.get('version', 'latest')).strip() or 'latest' + esMajor = str(data.get('esMajor', '8')).strip() or '8' + if esMajor not in ('7', '8', '9'): + esMajor = '8' + rabbitmqStream = str(data.get('rabbitmqStream', '4')).strip() or '4' + if rabbitmqStream not in ('3', '4'): + rabbitmqStream = '4' + confirmAction = bool(data.get('confirmAction', False)) + + support = managed_apps_os_support() + if not support['supported']: + data_ret = {'status': 0, 'error_message': support['reason']} + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + + if status in ('Removing', 'Upgrading') and not confirmAction: + data_ret = {'status': 0, 'error_message': 'Action confirmation is required.'} + json_data = json.dumps(data_ret) + return HttpResponse(json_data) if appName == 'Elasticsearch': if status == 'Installing': - command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --function InstallElasticSearch' + command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --app Elasticsearch --action install --version {0} --esMajor {1}'.format( + shlex.quote(version), shlex.quote(esMajor) + ) + elif status == 'Upgrading': + command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --app Elasticsearch --action upgrade --version {0} --esMajor {1}'.format( + shlex.quote(version), shlex.quote(esMajor) + ) else: - command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --function RemoveElasticSearch' + command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --app Elasticsearch --action remove' elif appName == 'Redis': if status == 'Installing': - command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --function InstallRedis' + command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --app Redis --action install --version {0}'.format( + shlex.quote(version) + ) + elif status == 'Upgrading': + command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --app Redis --action upgrade --version {0}'.format( + shlex.quote(version) + ) else: - command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --function RemoveRedis' + command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --app Redis --action remove' + elif appName == 'RabbitMQ': + if status == 'Installing': + command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --app RabbitMQ --action install --version {0} --rabbitmqStream {1}'.format( + shlex.quote(version), shlex.quote(rabbitmqStream) + ) + elif status == 'Upgrading': + command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --app RabbitMQ --action upgrade --version {0} --rabbitmqStream {1}'.format( + shlex.quote(version), shlex.quote(rabbitmqStream) + ) + else: + command = '/usr/local/CyberCP/bin/python /usr/local/CyberCP/manageServices/serviceManager.py --app RabbitMQ --action remove' + else: + data_ret = {'status': 0, 'error_message': 'Unknown application selected.'} + json_data = json.dumps(data_ret) + return HttpResponse(json_data) ProcessUtilities.popenExecutioner(command) data_ret = {'status': 1} diff --git a/plogical/pluginMigrationSQL.py b/plogical/pluginMigrationSQL.py new file mode 100644 index 000000000..d282029a1 --- /dev/null +++ b/plogical/pluginMigrationSQL.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- +""" +Plugin install/remove: robust DB migration and teardown for CyberPanel. + +- Prefer Django ``migrate`` / ``migrate zero``. +- If the global loader fails or migrate errors, apply ``sqlmigrate`` output and + ``--fake``, or drop plugin tables and clean ``django_migrations`` on removal. + +Assumes ``manage.py`` / ``CyberCP.settings`` are available under /usr/local/CyberCP. +""" + +from __future__ import annotations + +import os +import subprocess + + +def _manage_py() -> str: + return '/usr/local/CyberCP/manage.py' + + +def _python_executable() -> str: + for candidate in ('/usr/local/CyberCP/bin/python', '/usr/local/CyberCP/bin/python3'): + try: + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + except OSError: + continue + return 'python3' + + +def _django_setup(): + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'CyberCP.settings') + import django + + django.setup() + + +def _db_name() -> str: + _django_setup() + from django.conf import settings + + return settings.DATABASES['default']['NAME'] + + +def run_manage(args: list, cwd: str | None = None) -> subprocess.CompletedProcess: + """Run manage.py with args (e.g. ['migrate', 'contaboAutoSnapshot', '--noinput']).""" + cmd = [_python_executable(), _manage_py()] + args + return subprocess.run( + cmd, + cwd=cwd or '/usr/local/CyberCP', + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=600, + ) + + +def list_plugin_migration_names(plugin_name: str) -> list[str]: + """Sorted migration names from disk (e.g. 0001_initial), excluding __init__.""" + mig_dir = '/usr/local/CyberCP/' + plugin_name + '/migrations' + if not os.path.isdir(mig_dir): + return [] + names = [] + try: + for fn in os.listdir(mig_dir): + if not fn.endswith('.py') or fn == '__init__.py': + continue + names.append(fn[:-3]) + except OSError: + return [] + return sorted(names) + + +def applied_plugin_migrations(plugin_name: str) -> set[str]: + _django_setup() + from django.db import connection + from django.db.migrations.recorder import MigrationRecorder + + recorder = MigrationRecorder(connection) + return {name for app, name in recorder.applied_migrations() if app == plugin_name} + + +def sqlmigrate_text(plugin_name: str, migration_name: str) -> tuple[int, str]: + """Return (returncode, combined stdout+stderr) for sqlmigrate.""" + proc = run_manage(['sqlmigrate', plugin_name, migration_name]) + out = (proc.stdout or '') + (proc.stderr or '') + return proc.returncode, out + + +def _strip_sql_comments(sql: str) -> str: + lines = [] + for line in sql.splitlines(): + s = line.strip() + if s.startswith('--'): + continue + lines.append(line) + return '\n'.join(lines) + + +def _execute_multi_sql_mysql(sql: str) -> tuple[bool, str]: + """Execute multi-statement ``sqlmigrate`` output via ``mariadb`` CLI (socket auth as root).""" + sql = _strip_sql_comments(sql).strip() + if not sql: + return True, '' + db = _db_name() + try: + proc = subprocess.run( + ['mariadb', db], + input=sql.encode('utf-8', errors='replace'), + capture_output=True, + timeout=300, + ) + err = (proc.stderr or b'').decode('utf-8', errors='replace')[:2000] + if proc.returncode != 0: + return False, err or 'mariadb exited %s' % proc.returncode + return True, '' + except FileNotFoundError: + return False, 'mariadb client not found; install MariaDB client or fix PATH' + except Exception as e: + return False, str(e)[:2000] + + +def apply_pending_migrations_via_sql_and_fake(plugin_name: str, log) -> bool: + """ + For each on-disk migration not in django_migrations: sqlmigrate + execute, then + ``migrate --fake`` so each is recorded. + """ + pending = [m for m in list_plugin_migration_names(plugin_name) if m not in applied_plugin_migrations(plugin_name)] + if not pending: + log( + 'SQL fallback: no pending migrations recorded for %s — schema/DB mismatch may remain; ' + 'check migrate errors above.' + % plugin_name + ) + return False + for mig in pending: + rc, out = sqlmigrate_text(plugin_name, mig) + if rc != 0: + log('sqlmigrate %s %s failed (rc=%s): %s' % (plugin_name, mig, rc, out[:800])) + return False + ok, err = _execute_multi_sql_mysql(out) + if not ok: + log('SQL exec failed for %s %s: %s' % (plugin_name, mig, err)) + return False + log('Applied raw SQL for %s %s' % (plugin_name, mig)) + proc = run_manage(['migrate', plugin_name, mig, '--fake', '--noinput']) + if proc.returncode != 0: + log( + 'migrate --fake %s %s failed: %s' + % (plugin_name, mig, (proc.stderr or proc.stdout or '')[:800]) + ) + return False + return True + + +def drop_plugin_tables_and_migration_rows(plugin_name: str, log) -> bool: + """ + DROP all tables for this app label (prefix ``pluginName_``) and remove + django_migrations rows. Uses FOREIGN_KEY_CHECKS=0. + """ + _django_setup() + from django.db import connection + + db = _db_name() + prefix_a = plugin_name + '_' + prefix_b = plugin_name.lower() + '_' + tables = [] + try: + with connection.cursor() as c: + c.execute( + 'SELECT table_name FROM information_schema.tables WHERE table_schema = %s ' + 'AND (table_name LIKE %s OR table_name LIKE %s)', + [db, prefix_a + '%', prefix_b + '%'], + ) + tables = [row[0] for row in c.fetchall()] + except Exception as e: + log('list tables for drop failed: %s' % str(e)[:800]) + return False + if not tables: + log('No tables matched prefix for %s; cleaning django_migrations only.' % plugin_name) + try: + with connection.cursor() as c: + c.execute('SET FOREIGN_KEY_CHECKS=0') + for t in tables: + safe = t.replace('`', '``') + c.execute('DROP TABLE IF EXISTS `%s`' % safe) + c.execute('SET FOREIGN_KEY_CHECKS=1') + c.execute('DELETE FROM django_migrations WHERE app = %s', [plugin_name]) + log('Dropped %s table(s) for %s and removed django_migrations rows.' % (len(tables), plugin_name)) + return True + except Exception as e: + log('drop_plugin_tables failed: %s' % str(e)[:800]) + return False + + +def ensure_login_system_migrations_applied(log) -> None: + """ + Apply loginSystem 0001 if present (graph fix). Safe no-op if already applied. + """ + mig_path = '/usr/local/CyberCP/loginSystem/migrations/0001_initial.py' + if not os.path.isfile(mig_path): + return + proc = run_manage(['migrate', 'loginSystem', '--noinput']) + if proc.returncode != 0: + log( + 'migrate loginSystem exited %s (panels may need manual fix): %s' + % (proc.returncode, (proc.stderr or proc.stdout or '')[:600]) + ) diff --git a/plogical/upgrade.py b/plogical/upgrade.py index 8241e1c1f..c702a55d4 100644 --- a/plogical/upgrade.py +++ b/plogical/upgrade.py @@ -4434,6 +4434,86 @@ class Migration(migrations.Migration): except: pass + @staticmethod + def rabbitMQMigrations(): + marker_path = '/home/cyberpanel/rabbitmq' + rabbitmq_service_files = [ + '/usr/lib/systemd/system/rabbitmq-server.service', + '/lib/systemd/system/rabbitmq-server.service' + ] + rabbitmq_binary_paths = [ + '/usr/sbin/rabbitmq-server', + '/usr/lib/rabbitmq/bin/rabbitmq-server' + ] + + try: + rabbitmq_installed = any(os.path.exists(path) for path in rabbitmq_service_files + rabbitmq_binary_paths) + + if rabbitmq_installed: + if not os.path.exists(marker_path): + writeToFile = open(marker_path, 'w+') + writeToFile.close() + Upgrade.stdOut('RabbitMQ detected during upgrade. Marker file created.', 0) + + Upgrade.executioner('systemctl enable rabbitmq-server', 'Enable RabbitMQ service', 0) + Upgrade.executioner('systemctl start rabbitmq-server', 'Start RabbitMQ service', 0) + else: + if os.path.exists(marker_path): + os.remove(marker_path) + Upgrade.stdOut('RabbitMQ marker removed because service is not installed.', 0) + except BaseException as msg: + Upgrade.stdOut('RabbitMQ migration failed: ' + str(msg), 0) + + @staticmethod + def redisMigrations(): + marker_path = '/home/cyberpanel/redis' + redis_binary = '/usr/bin/redis-server' + redis_service_files = [ + '/usr/lib/systemd/system/redis.service', + '/lib/systemd/system/redis.service' + ] + + try: + redis_installed = os.path.exists(redis_binary) or any(os.path.exists(path) for path in redis_service_files) + if redis_installed: + if not os.path.exists(marker_path): + writeToFile = open(marker_path, 'w+') + writeToFile.close() + Upgrade.stdOut('Redis detected during upgrade. Marker file created.', 0) + Upgrade.executioner('systemctl enable redis', 'Enable Redis service', 0) + Upgrade.executioner('systemctl start redis', 'Start Redis service', 0) + else: + if os.path.exists(marker_path): + os.remove(marker_path) + Upgrade.stdOut('Redis marker removed because service is not installed.', 0) + except BaseException as msg: + Upgrade.stdOut('Redis migration failed: ' + str(msg), 0) + + @staticmethod + def elasticSearchMigrations(): + marker_path = '/home/cyberpanel/elasticsearch' + es_binary = '/usr/share/elasticsearch/bin/elasticsearch' + es_service_files = [ + '/usr/lib/systemd/system/elasticsearch.service', + '/lib/systemd/system/elasticsearch.service' + ] + + try: + es_installed = os.path.exists(es_binary) or any(os.path.exists(path) for path in es_service_files) + if es_installed: + if not os.path.exists(marker_path): + writeToFile = open(marker_path, 'w+') + writeToFile.close() + Upgrade.stdOut('Elasticsearch detected during upgrade. Marker file created.', 0) + Upgrade.executioner('systemctl enable elasticsearch', 'Enable Elasticsearch service', 0) + Upgrade.executioner('systemctl start elasticsearch', 'Start Elasticsearch service', 0) + else: + if os.path.exists(marker_path): + os.remove(marker_path) + Upgrade.stdOut('Elasticsearch marker removed because service is not installed.', 0) + except BaseException as msg: + Upgrade.stdOut('Elasticsearch migration failed: ' + str(msg), 0) + @staticmethod def backupCriticalFiles(): """Backup all critical configuration files before upgrade""" @@ -6652,6 +6732,9 @@ slowlog = /var/log/php{version}-fpm-slow.log Upgrade.setupWebmail() Upgrade.setupSieve() Upgrade.enableServices() + Upgrade.elasticSearchMigrations() + Upgrade.redisMigrations() + Upgrade.rabbitMQMigrations() # Apply AlmaLinux 9 fixes before other installations Upgrade.fix_almalinux9_mariadb() diff --git a/pluginHolder/templates/pluginHolder/plugins.html b/pluginHolder/templates/pluginHolder/plugins.html index c989aa639..b351c7a4a 100644 --- a/pluginHolder/templates/pluginHolder/plugins.html +++ b/pluginHolder/templates/pluginHolder/plugins.html @@ -1378,6 +1378,9 @@ +
@@ -1414,7 +1417,7 @@
{% for plugin in plugins %} -
+
{% if plugin.type|lower == "security" %} @@ -1561,7 +1564,7 @@ {% for plugin in plugins %} - + {{ plugin.name }} {% if plugin.freshness_badge %} @@ -1871,15 +1874,127 @@
diff --git a/pluginHolder/views.py b/pluginHolder/views.py index 99f4a9a7f..69a8eb741 100644 --- a/pluginHolder/views.py +++ b/pluginHolder/views.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- from django.shortcuts import render, redirect -from django.http import JsonResponse +from django.http import JsonResponse, StreamingHttpResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from plogical.mailUtilities import mailUtilities @@ -2441,6 +2441,7 @@ def plugin_settings_proxy(request, plugin_name): settings_view = getattr(views_mod, candidate, None) if callable(settings_view): response = settings_view(request) + response = _inject_plugin_settings_back_bar(response) return _inject_activation_store_hook(response, plugin_name) except ModuleNotFoundError as e: last_err = str(e) @@ -2458,6 +2459,63 @@ def plugin_settings_proxy(request, plugin_name): return HttpResponseNotFound('Plugin not found.') +def _inject_plugin_settings_back_bar(response): + """ + Add a consistent 'Back to installed Plugins' link at the top of proxied plugin + settings HTML so users need not use the sidebar. Only touches HTML responses. + """ + try: + if isinstance(response, StreamingHttpResponse): + return response + content_type = (response.get('Content-Type', '') or '').lower() + if 'text/html' not in content_type: + return response + if not getattr(response, 'content', None): + return response + body = response.content.decode('utf-8', errors='ignore') + if not body.strip(): + return response + from django.utils.html import escape + from django.utils.translation import gettext as _ + + if 'cp-plugin-settings-back' in body: + return response + label = escape(str(_('Back to installed Plugins'))) + # Inside #main-content only: avoids full-viewport strip over the sidebar (baseTemplate uses + # #sidebar + #main-content { margin-left: 260px; }). + back_html = ( + '
' + ) + main_content_m = re.search( + r'(]*>)', + body, + flags=re.IGNORECASE, + ) + if main_content_m: + pos = main_content_m.end() + body = body[:pos] + back_html + body[pos:] + elif re.search(r']*>', body, flags=re.IGNORECASE): + body = re.sub( + r'(]*>)', + r'\1' + back_html, + body, + count=1, + flags=re.IGNORECASE, + ) + else: + body = back_html + body + response.content = body.encode('utf-8') + return response + except Exception: + return response + + def _inject_activation_store_hook(response, plugin_name): """ Tiny safety hook for plugin settings pages: diff --git a/pluginInstaller/pluginInstaller.py b/pluginInstaller/pluginInstaller.py index c0d6018ef..145a336ab 100644 --- a/pluginInstaller/pluginInstaller.py +++ b/pluginInstaller/pluginInstaller.py @@ -371,6 +371,17 @@ class pluginInstaller: py = pluginInstaller._manage_python_executable() try: os.chdir('/usr/local/CyberCP') + try: + from plogical import pluginMigrationSQL as _pmig + except ImportError: + _pmig = None + + def _mig_log(msg): + pluginInstaller.stdOut(msg) + + if _pmig is not None: + _pmig.ensure_login_system_migrations_applied(_mig_log) + mk = subprocess.call( [py, manage_py, 'makemigrations', pluginName], stdin=subprocess.DEVNULL, @@ -379,14 +390,59 @@ class pluginInstaller: pluginInstaller.stdOut( 'makemigrations %s exited %s (ok if no model changes)' % (pluginName, mk) ) - mig = subprocess.call( + proc = subprocess.run( [py, manage_py, 'migrate', pluginName, '--noinput'], + cwd='/usr/local/CyberCP', stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=600, ) - if mig != 0: + if proc.returncode != 0: + err_tail = (proc.stderr or proc.stdout or '').strip()[:900] pluginInstaller.stdOut( - 'migrate %s exited %s — check CyberPanel logs and DB permissions' % (pluginName, mig) + 'migrate %s exited %s — %s' + % (pluginName, proc.returncode, err_tail or 'no stderr') ) + if _pmig is not None: + pluginInstaller.stdOut( + 'Attempting SQL + migrate --fake fallback for %s..' % pluginName + ) + if _pmig.apply_pending_migrations_via_sql_and_fake(pluginName, _mig_log): + pluginInstaller.stdOut('SQL migration fallback succeeded for %s; re-running migrate..' % pluginName) + proc2 = subprocess.run( + [py, manage_py, 'migrate', pluginName, '--noinput'], + cwd='/usr/local/CyberCP', + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=600, + ) + if proc2.returncode != 0: + pluginInstaller.stdOut( + 'migrate %s still failed after SQL fallback (rc=%s): %s' + % ( + pluginName, + proc2.returncode, + (proc2.stderr or proc2.stdout or '')[:600], + ) + ) + else: + pluginInstaller.stdOut('migrate %s completed after SQL fallback.' % pluginName) + else: + pluginInstaller.stdOut( + 'SQL migration fallback failed for %s — check DB user, mariadb client, and logs.' + % pluginName + ) + try: + from plogical.CyberCPLogFileWriter import CyberCPLogFileWriter as _cp_log + + _cp_log.writeToFile( + 'pluginInstaller.installMigrations %s: migrate rc=%s %s' + % (pluginName, proc.returncode, err_tail[:400]) + ) + except Exception: + pass finally: try: os.chdir(currentDir) @@ -674,13 +730,53 @@ class pluginInstaller: @staticmethod def removeMigrations(pluginName): currentDir = os.getcwd() - os.chdir('/usr/local/CyberCP') - py = pluginInstaller._manage_python_executable() - subprocess.call( - [py, '/usr/local/CyberCP/manage.py', 'migrate', pluginName, 'zero', '--noinput'], - stdin=subprocess.DEVNULL, - ) - os.chdir(currentDir) + try: + os.chdir('/usr/local/CyberCP') + py = pluginInstaller._manage_python_executable() + try: + from plogical import pluginMigrationSQL as _pmig + except ImportError: + _pmig = None + + def _mig_log(msg): + pluginInstaller.stdOut(msg) + + if _pmig is not None: + _pmig.ensure_login_system_migrations_applied(_mig_log) + + proc = subprocess.run( + [py, '/usr/local/CyberCP/manage.py', 'migrate', pluginName, 'zero', '--noinput'], + cwd='/usr/local/CyberCP', + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=600, + ) + if proc.returncode != 0: + err_tail = (proc.stderr or proc.stdout or '').strip()[:900] + pluginInstaller.stdOut( + 'migrate %s zero exited %s — %s' + % (pluginName, proc.returncode, err_tail or 'no stderr') + ) + if _pmig is not None: + pluginInstaller.stdOut( + 'Attempting DROP TABLE + django_migrations cleanup for %s..' % pluginName + ) + _pmig.drop_plugin_tables_and_migration_rows(pluginName, _mig_log) + try: + from plogical.CyberCPLogFileWriter import CyberCPLogFileWriter as _cp_log + + _cp_log.writeToFile( + 'pluginInstaller.removeMigrations %s: zero rc=%s %s' + % (pluginName, proc.returncode, err_tail[:400]) + ) + except Exception: + pass + finally: + try: + os.chdir(currentDir) + except OSError: + pass @staticmethod def removePlugin(pluginName): diff --git a/public/static/manageServices/manageServices.js b/public/static/manageServices/manageServices.js index 949fab41c..c3f419f2c 100644 --- a/public/static/manageServices/manageServices.js +++ b/public/static/manageServices/manageServices.js @@ -428,10 +428,505 @@ app.controller('pureFTPD', function ($scope, $http, $timeout, $window) { app.controller('manageApplications', function ($scope, $http, $timeout, $window) { + /** + * Normalize entries from applicationMeta (strings, numbers, or rare object shapes) + * so version pickers never show blank rows. CyberPanel uses {$ ... $} interpolation + * in templates; list labels use versionLabel() for consistent display. + */ + function normalizeVersionToken(v) { + if (v === null || v === undefined) { + return ''; + } + if (v === 'latest') { + return 'latest'; + } + if (typeof v === 'number' && isFinite(v)) { + return String(v); + } + if (angular.isObject(v)) { + var o = v; + var cand = o.version || o.Version || o.value || o.name || o.ver || o.label; + if (cand !== undefined && cand !== null) { + return String(cand).trim(); + } + try { + return JSON.stringify(o); + } catch (ignore) { + return ''; + } + } + return String(v).trim(); + } + + function sanitizeVersionsArray(vers) { + if (!angular.isArray(vers)) { + return []; + } + var out = []; + var seen = {}; + vers.forEach(function (raw) { + var t = normalizeVersionToken(raw); + if (!t || t === 'latest') { + return; + } + if (!seen[t]) { + seen[t] = true; + out.push(t); + } + }); + return out; + } + + function versionMatchesRabbitmqStream(ver, stream) { + var s = String(stream || '4').trim(); + var t = normalizeVersionToken(ver); + if (!t || t === 'latest') { + return false; + } + var m = /^(\d+)\./.exec(t); + return !!(m && m[1] === s); + } + + function versionMatchesEsMajor(ver, major) { + var mjr = String(major || '8').trim(); + var t = normalizeVersionToken(ver); + if (!t || t === 'latest') { + return false; + } + var m = /^(\d+)\./.exec(t); + return !!(m && m[1] === mjr); + } + + $scope.versionLabel = function (v) { + if (v === 'latest') { + return 'latest'; + } + var t = normalizeVersionToken(v); + return t || '(unknown)'; + }; + + $scope.versionTrackId = function (idx, v) { + return String(idx) + '|' + $scope.versionLabel(v); + }; + + /* false = long-running install/remove/poll (show spinners); true = idle */ $scope.cyberpanelLoading = true; + /** Background applicationMeta refresh — separate from cyberpanelLoading so the page does not “freeze” on every modal open. */ + $scope.appsMetaRefreshing = false; + $scope.apps = [ + {name: 'Elasticsearch', image: '/static/manageServices/images/elastic-search.png'}, + {name: 'Redis', image: '/static/manageServices/images/redis.png'}, + {name: 'RabbitMQ', image: '/static/manageServices/images/rabbitmq-logo.svg'} + ]; - $scope.removeInstall = function (appName, status) { + (function mergeMetaBootstrap() { + var el = document.getElementById('manageApplicationsMetaBootstrap'); + if (!el || !el.textContent) { + return; + } + var raw = el.textContent.trim(); + if (!raw) { + return; + } + try { + var boot = JSON.parse(raw); + if (!boot || Number(boot.status) !== 1) { + return; + } + var appMap = {}; + (boot.apps || []).forEach(function (a) { + if (a && a.name) { + appMap[a.name] = a; + } + }); + $scope.apps = $scope.apps.map(function (baseApp) { + var meta = appMap[baseApp.name] || {}; + var vers = meta.versions; + if (!angular.isArray(vers)) { + vers = []; + } + vers = sanitizeVersionsArray(vers); + return { + name: baseApp.name, + image: baseApp.image, + installed: !!meta.installed, + installedVersion: meta.installedVersion || '', + updateAvailable: !!meta.updateAvailable, + crossBranchUpdateSuggested: !!meta.crossBranchUpdateSuggested, + versions: vers, + latestAvailable: meta.latestAvailable || '', + latestOverall: meta.latestOverall || '', + rabbitmqVersionsHint: meta.rabbitmqVersionsHint || '' + }; + }); + } catch (ignore) { + /* keep bare apps list */ + } + })(); + $scope.selectedVersion = 'latest'; + /** Row highlight uses $index so only one row looks selected (avoids sticky :focus / repeater quirks). */ + $scope.selectedVersionRowIndex = 0; + $scope.selectedVersions = ['latest']; + + $scope.recalcSelectedVersionRowIndex = function () { + var list = $scope.selectedVersions || []; + var sel = $scope.selectedVersion; + var i = list.indexOf(sel); + if (i < 0) { + var normSel = normalizeVersionToken(sel); + if (normSel) { + for (var j = 0; j < list.length; j += 1) { + if (normalizeVersionToken(list[j]) === normSel) { + i = j; + $scope.selectedVersion = list[j]; + break; + } + } + } + } + $scope.selectedVersionRowIndex = (i >= 0) ? i : 0; + }; + + $scope.selectManagedAppVersion = function (idx, v, $event) { + var n = (typeof idx === 'number') ? idx : parseInt(idx, 10); + if (!isFinite(n) || n < 0) { + n = 0; + } + $scope.selectedVersionRowIndex = n; + $scope.selectedVersion = v; + if ($event && $event.target && typeof $event.target.blur === 'function') { + $event.target.blur(); + } else if (typeof document !== 'undefined' && document.activeElement && typeof document.activeElement.blur === 'function') { + document.activeElement.blur(); + } + }; + $scope.selectedEsMajor = '8'; + $scope.selectedRabbitmqStream = '4'; + /** RabbitMQ: 4.x is default for new installs (metadata prefetched). Upgrade may require picking stream if version line is unknown. ES major still user-picked before version list loads. */ + $scope.rabbitmqBranchChosen = false; + $scope.esMajorChosen = false; + $scope.confirmAction = false; + $scope.selectedCurrentVersion = ''; + + $scope.chooseRabbitmqStream = function (stream) { + var s = String(stream || '4').trim(); + if (s !== '3' && s !== '4') { + s = '4'; + } + $scope.selectedRabbitmqStream = s; + $scope.rabbitmqBranchChosen = true; + $scope.refreshMeta(); + }; + + $scope.chooseEsMajor = function (major) { + var m = String(major || '8').trim(); + if (m !== '7' && m !== '8' && m !== '9') { + m = '8'; + } + $scope.selectedEsMajor = m; + $scope.esMajorChosen = true; + $scope.refreshMeta(); + }; + + /** + * When the install/upgrade modal is open, re-apply version list from latest applicationMeta. + * (Page-load meta can be empty for ES if dnf was slow; opening the modal must refetch.) + */ + $scope.syncModalVersionLists = function () { + if (!$scope.appName || ($scope.status !== 'Installing' && $scope.status !== 'Upgrading')) { + return; + } + if ($scope.appName !== 'Elasticsearch' && $scope.appName !== 'Redis' && $scope.appName !== 'RabbitMQ') { + return; + } + if ($scope.appName === 'RabbitMQ' && !$scope.rabbitmqBranchChosen) { + $scope.selectedVersions = ['latest']; + $scope.selectedVersion = 'latest'; + $scope.repoShowsOnlyOneStream = false; + $scope.recalcSelectedVersionRowIndex(); + return; + } + if ($scope.appName === 'Elasticsearch' && !$scope.esMajorChosen) { + $scope.selectedVersions = ['latest']; + $scope.selectedVersion = 'latest'; + $scope.repoShowsOnlyOneStream = false; + $scope.recalcSelectedVersionRowIndex(); + return; + } + var meta = $scope.findAppMeta($scope.appName); + var vers = sanitizeVersionsArray((meta && meta.versions) ? meta.versions : []); + $scope.selectedVersions = ['latest'].concat(vers); + var curRaw = (meta && meta.installedVersion) ? meta.installedVersion : ($scope.selectedCurrentVersion || ''); + var cur = normalizeVersionToken(curRaw) || String(curRaw || '').trim(); + if (cur && $scope.selectedVersions.indexOf(cur) === -1) { + var allowCur = true; + if ($scope.appName === 'RabbitMQ') { + allowCur = versionMatchesRabbitmqStream(cur, $scope.selectedRabbitmqStream); + } else if ($scope.appName === 'Elasticsearch') { + allowCur = versionMatchesEsMajor(cur, $scope.selectedEsMajor); + } + if (allowCur) { + $scope.selectedVersions.push(cur); + } + } + if (cur) { + $scope.selectedCurrentVersion = cur; + } + var prevSel = $scope.selectedVersion; + if (prevSel && $scope.selectedVersions.indexOf(prevSel) !== -1) { + $scope.selectedVersion = prevSel; + } else { + $scope.selectedVersion = 'latest'; + } + var realVers = ($scope.selectedVersions || []).filter(function (v) { + return v && v !== 'latest'; + }); + $scope.repoShowsOnlyOneStream = ($scope.status === 'Upgrading' && realVers.length <= 1); + $scope.recalcSelectedVersionRowIndex(); + }; + + $scope.refreshMeta = function () { + $scope.appsMetaRefreshing = true; + var url = "/manageservices/applicationMeta"; + var data = { + esMajor: $scope.selectedEsMajor, + rabbitmqStream: $scope.selectedRabbitmqStream + }; + var config = { + headers: { + 'Content-Type': 'application/json;charset=UTF-8', + 'X-CSRFToken': getCookie('csrftoken') + }, + transformRequest: function (payload) { + return angular.toJson(payload); + } + }; + + return $http.post(url, data, config).then(function (response) { + $scope.appsMetaRefreshing = false; + var payload = response.data; + var ok = payload && (payload.status === 1 || payload.status === '1'); + if (ok) { + var appMap = {}; + (payload.apps || []).forEach(function (app) { + appMap[app.name] = app; + }); + var esMetaResp = appMap['Elasticsearch']; + var rmqMetaResp = appMap['RabbitMQ']; + var respEsMaj = String(esMetaResp && esMetaResp.major != null ? esMetaResp.major : '').trim(); + if (respEsMaj && respEsMaj !== String($scope.selectedEsMajor || '8').trim()) { + return; + } + var respRmqStream = String(rmqMetaResp && rmqMetaResp.rabbitmqStream != null ? rmqMetaResp.rabbitmqStream : '').trim(); + if (respRmqStream && respRmqStream !== String($scope.selectedRabbitmqStream || '4').trim()) { + return; + } + $scope.apps = $scope.apps.map(function (baseApp) { + var meta = appMap[baseApp.name] || {}; + var vers = meta.versions; + if (!angular.isArray(vers)) { + vers = []; + } + vers = sanitizeVersionsArray(vers); + return { + name: baseApp.name, + image: baseApp.image, + installed: !!meta.installed, + installedVersion: meta.installedVersion || '', + updateAvailable: !!meta.updateAvailable, + crossBranchUpdateSuggested: !!meta.crossBranchUpdateSuggested, + versions: vers, + latestAvailable: meta.latestAvailable || '', + latestOverall: meta.latestOverall || '', + rabbitmqVersionsHint: meta.rabbitmqVersionsHint || '' + }; + }); + $scope.syncModalVersionLists(); + } else { + new PNotify({ + title: 'Operation Failed!', + text: (payload && (payload.error_message || payload.errorMessage)) || 'Could not load application metadata.', + type: 'error' + }); + } + }, function () { + $scope.appsMetaRefreshing = false; + new PNotify({ + title: 'Operation Failed!', + text: 'Could not connect to server, please refresh this page', + type: 'error' + }); + }); + }; + + $scope.prepareAction = function (service, status, bootstrapInstalledVersion) { + if (bootstrapInstalledVersion === undefined || bootstrapInstalledVersion === null) { + bootstrapInstalledVersion = ''; + } else { + bootstrapInstalledVersion = String(bootstrapInstalledVersion).trim(); + } + $scope.status = status; + $scope.appName = service.name; + $scope.confirmAction = false; + var effectiveInstalled = (service.installedVersion || bootstrapInstalledVersion || '').trim(); + $scope.selectedCurrentVersion = effectiveInstalled; + + if (service.name === 'RabbitMQ') { + if (effectiveInstalled && /^4\./.test(effectiveInstalled)) { + $scope.selectedRabbitmqStream = '4'; + } else if (effectiveInstalled && /^3\./.test(effectiveInstalled)) { + $scope.selectedRabbitmqStream = '3'; + } else if (status === 'Installing') { + $scope.selectedRabbitmqStream = '4'; + } + if (status === 'Upgrading' && effectiveInstalled) { + if (/^4\./.test(effectiveInstalled) || /^3\./.test(effectiveInstalled)) { + $scope.rabbitmqBranchChosen = true; + } + } + } + + if (service.name === 'Elasticsearch' && effectiveInstalled) { + var iv = effectiveInstalled; + if (/^9\./.test(iv)) { + $scope.selectedEsMajor = '9'; + } else if (/^8\./.test(iv)) { + $scope.selectedEsMajor = '8'; + } else if (/^7\./.test(iv)) { + $scope.selectedEsMajor = '7'; + } + } + + $scope.selectedVersions = ['latest']; + // RabbitMQ upgrade: bootstrap meta is often stream 4; stream follows installed line — do not + // reuse service.versions until refreshMeta returns for selectedRabbitmqStream (avoids mismatched list). + var deferVersionList = (service.name === 'RabbitMQ' && (!$scope.rabbitmqBranchChosen || status === 'Upgrading')) + || (service.name === 'Elasticsearch' && !$scope.esMajorChosen); + if (!deferVersionList) { + var svcVers = sanitizeVersionsArray(service.versions || []); + if (svcVers.length > 0) { + $scope.selectedVersions = ['latest'].concat(svcVers); + } + var curPick = normalizeVersionToken(effectiveInstalled) || effectiveInstalled; + if (curPick && $scope.selectedVersions.indexOf(curPick) === -1) { + $scope.selectedVersions.push(curPick); + } + } + $scope.selectedVersion = 'latest'; + $scope.requestData = ''; + + var realVers = ($scope.selectedVersions || []).filter(function (v) { + return v && v !== 'latest'; + }); + if (deferVersionList) { + $scope.repoShowsOnlyOneStream = false; + } else { + $scope.repoShowsOnlyOneStream = ($scope.status === 'Upgrading' && realVers.length <= 1); + } + $scope.recalcSelectedVersionRowIndex(); + }; + + $scope.findAppMeta = function (appName) { + var found = null; + ($scope.apps || []).forEach(function (item) { + if (item.name === appName) { + found = item; + } + }); + return found || {}; + }; + + $scope.prepareActionByName = function (appName, status, bootstrapInstalledVersion) { + var meta = $scope.findAppMeta(appName); + if (!meta.name) { + meta = {name: appName, versions: []}; + } + var mver = meta.versions; + if (!angular.isArray(mver)) { + mver = []; + } + var merged = { + name: meta.name, + image: meta.image, + installed: meta.installed, + installedVersion: meta.installedVersion || '', + versions: mver + }; + $scope.prepareAction(merged, status, bootstrapInstalledVersion); + }; + + /** + * Prepare scope then show modal (do not use data-toggle + ng-click — Bootstrap can + * open the dialog before Angular runs ng-click, leaving status/appName unset). + * For managed apps, wait for applicationMeta so version lists and installedVersion are correct (upgrade vs downgrade). + */ + $scope.openApplicationsModal = function (appName, status, bootstrapInstalledVersion) { + var needMeta = (appName === 'RabbitMQ' || appName === 'Elasticsearch' || appName === 'Redis'); + var showModal = function () { + if (typeof window.jQuery !== 'undefined' && jQuery('#settings').modal) { + jQuery('#settings').modal('show'); + } + }; + if (needMeta) { + if (appName === 'RabbitMQ') { + if (status === 'Installing') { + $scope.selectedRabbitmqStream = '4'; + $scope.rabbitmqBranchChosen = true; + } else { + $scope.rabbitmqBranchChosen = false; + } + } + if (appName === 'Elasticsearch') { + $scope.esMajorChosen = false; + } + // RabbitMQ install: default 4.x stream and prefetch metadata. Upgrade: pick stream from installed version. + // Elasticsearch: still wait for user major. Redis refreshes on open. + $scope.appName = appName; + $scope.status = status; + $scope.prepareActionByName(appName, status, bootstrapInstalledVersion); + showModal(); + if (appName === 'Redis' || (appName === 'RabbitMQ' && $scope.rabbitmqBranchChosen)) { + $timeout(function () { + $scope.refreshMeta(); + }, 0); + } + } else { + $scope.prepareActionByName(appName, status, bootstrapInstalledVersion); + showModal(); + } + }; + + $scope.runAction = function () { + var appName = $scope.appName; + var status = $scope.status; + if (!appName || !status) { + return; + } + if ((status === 'Removing' || status === 'Upgrading') && !$scope.confirmAction) { + new PNotify({ + title: 'Confirmation Required', + text: 'Please confirm this action before proceeding.', + type: 'warning' + }); + return; + } + if (appName === 'RabbitMQ' && (status === 'Installing' || status === 'Upgrading') && !$scope.rabbitmqBranchChosen) { + new PNotify({ + title: 'Stream required', + text: 'Choose RabbitMQ 4.x or 3.x above to load versions for that line.', + type: 'warning' + }); + return; + } + if (appName === 'Elasticsearch' && (status === 'Installing' || status === 'Upgrading') && !$scope.esMajorChosen) { + new PNotify({ + title: 'Major version required', + text: 'Choose Elasticsearch major (7, 8, or 9) above to load versions for that line.', + type: 'warning' + }); + return; + } $scope.status = status; $scope.appName = appName; @@ -441,7 +936,11 @@ app.controller('manageApplications', function ($scope, $http, $timeout, $window) var data = { appName: appName, - status: status + status: status, + version: $scope.selectedVersion || 'latest', + esMajor: $scope.selectedEsMajor || '8', + rabbitmqStream: $scope.selectedRabbitmqStream || '4', + confirmAction: $scope.confirmAction === true }; var config = { @@ -524,6 +1023,18 @@ app.controller('manageApplications', function ($scope, $http, $timeout, $window) } + // Do not fetch package metadata on page load; it can block workers under DNF load. + // Metadata is fetched on-demand when opening install/version-change modals. + + if (typeof window.jQuery !== 'undefined' && jQuery.fn.on) { + jQuery('#settings').on('hidden.bs.modal', function () { + $scope.$evalAsync(function () { + $scope.rabbitmqBranchChosen = false; + $scope.esMajorChosen = false; + }); + }); + } + }); /* Java script code */ \ No newline at end of file diff --git a/static/dockerManager/dockerManager.js b/static/dockerManager/dockerManager.js index ba9569bef..e89dcf7c0 100644 --- a/static/dockerManager/dockerManager.js +++ b/static/dockerManager/dockerManager.js @@ -974,6 +974,15 @@ app.controller('listContainers', function ($scope, $http) { return; } + if ($scope.dockerUpdateInProgress) { + new PNotify({ + title: 'Update in progress', + text: 'Wait until the current update finishes before starting another.', + type: 'warning' + }); + return; + } + // If no new image specified, use current image if (!$scope.newImage) { $scope.newImage = $scope.currentImage; @@ -1000,9 +1009,18 @@ app.controller('listContainers', function ($scope, $http) { history: false } })).get().on('pnotify.confirm', function () { + var dockerUpdateNotificationId = null; + $scope.dockerUpdateInProgress = true; $('#imageLoading').show(); $("#updateContainer").modal("hide"); + if (typeof window.cpDockerUpdateNotifyStart === 'function') { + dockerUpdateNotificationId = window.cpDockerUpdateNotifyStart( + $scope.updateContainerName, + $scope.newImage + ':' + $scope.newTag + ); + } + url = "/docker/updateContainer"; var data = { name: $scope.updateContainerName, @@ -1020,15 +1038,27 @@ app.controller('listContainers', function ($scope, $http) { function ListInitialData(response) { console.log(response); + $scope.dockerUpdateInProgress = false; $('#imageLoading').hide(); - if (response.data.updateContainerStatus === 1) { + var ok = response.data && response.data.updateContainerStatus === 1; + var imgLabel = ok + ? (response.data.new_image || response.data.message || 'Updated') + : (response.data && response.data.error_message ? response.data.error_message : 'Update failed'); + + if (typeof window.cpDockerUpdateNotifyEnd === 'function' && dockerUpdateNotificationId) { + window.cpDockerUpdateNotifyEnd(dockerUpdateNotificationId, ok, imgLabel); + } + + if (ok) { new PNotify({ title: 'Container Updated Successfully', - text: `Container updated to ${response.data.new_image}`, + text: 'Container updated to ' + (response.data.new_image || response.data.message || 'new image'), type: 'success' }); - location.reload(); + setTimeout(function () { + location.reload(); + }, 2200); } else { new PNotify({ title: 'Update Failed', @@ -1039,7 +1069,11 @@ app.controller('listContainers', function ($scope, $http) { } function cantLoadInitialData(response) { + $scope.dockerUpdateInProgress = false; $('#imageLoading').hide(); + if (typeof window.cpDockerUpdateNotifyEnd === 'function' && dockerUpdateNotificationId) { + window.cpDockerUpdateNotifyEnd(dockerUpdateNotificationId, false, 'Could not connect to server'); + } new PNotify({ title: 'Update Failed', text: 'Could not connect to server', diff --git a/static/manageServices/manageServices.js b/static/manageServices/manageServices.js index 949fab41c..c3f419f2c 100644 --- a/static/manageServices/manageServices.js +++ b/static/manageServices/manageServices.js @@ -428,10 +428,505 @@ app.controller('pureFTPD', function ($scope, $http, $timeout, $window) { app.controller('manageApplications', function ($scope, $http, $timeout, $window) { + /** + * Normalize entries from applicationMeta (strings, numbers, or rare object shapes) + * so version pickers never show blank rows. CyberPanel uses {$ ... $} interpolation + * in templates; list labels use versionLabel() for consistent display. + */ + function normalizeVersionToken(v) { + if (v === null || v === undefined) { + return ''; + } + if (v === 'latest') { + return 'latest'; + } + if (typeof v === 'number' && isFinite(v)) { + return String(v); + } + if (angular.isObject(v)) { + var o = v; + var cand = o.version || o.Version || o.value || o.name || o.ver || o.label; + if (cand !== undefined && cand !== null) { + return String(cand).trim(); + } + try { + return JSON.stringify(o); + } catch (ignore) { + return ''; + } + } + return String(v).trim(); + } + + function sanitizeVersionsArray(vers) { + if (!angular.isArray(vers)) { + return []; + } + var out = []; + var seen = {}; + vers.forEach(function (raw) { + var t = normalizeVersionToken(raw); + if (!t || t === 'latest') { + return; + } + if (!seen[t]) { + seen[t] = true; + out.push(t); + } + }); + return out; + } + + function versionMatchesRabbitmqStream(ver, stream) { + var s = String(stream || '4').trim(); + var t = normalizeVersionToken(ver); + if (!t || t === 'latest') { + return false; + } + var m = /^(\d+)\./.exec(t); + return !!(m && m[1] === s); + } + + function versionMatchesEsMajor(ver, major) { + var mjr = String(major || '8').trim(); + var t = normalizeVersionToken(ver); + if (!t || t === 'latest') { + return false; + } + var m = /^(\d+)\./.exec(t); + return !!(m && m[1] === mjr); + } + + $scope.versionLabel = function (v) { + if (v === 'latest') { + return 'latest'; + } + var t = normalizeVersionToken(v); + return t || '(unknown)'; + }; + + $scope.versionTrackId = function (idx, v) { + return String(idx) + '|' + $scope.versionLabel(v); + }; + + /* false = long-running install/remove/poll (show spinners); true = idle */ $scope.cyberpanelLoading = true; + /** Background applicationMeta refresh — separate from cyberpanelLoading so the page does not “freeze” on every modal open. */ + $scope.appsMetaRefreshing = false; + $scope.apps = [ + {name: 'Elasticsearch', image: '/static/manageServices/images/elastic-search.png'}, + {name: 'Redis', image: '/static/manageServices/images/redis.png'}, + {name: 'RabbitMQ', image: '/static/manageServices/images/rabbitmq-logo.svg'} + ]; - $scope.removeInstall = function (appName, status) { + (function mergeMetaBootstrap() { + var el = document.getElementById('manageApplicationsMetaBootstrap'); + if (!el || !el.textContent) { + return; + } + var raw = el.textContent.trim(); + if (!raw) { + return; + } + try { + var boot = JSON.parse(raw); + if (!boot || Number(boot.status) !== 1) { + return; + } + var appMap = {}; + (boot.apps || []).forEach(function (a) { + if (a && a.name) { + appMap[a.name] = a; + } + }); + $scope.apps = $scope.apps.map(function (baseApp) { + var meta = appMap[baseApp.name] || {}; + var vers = meta.versions; + if (!angular.isArray(vers)) { + vers = []; + } + vers = sanitizeVersionsArray(vers); + return { + name: baseApp.name, + image: baseApp.image, + installed: !!meta.installed, + installedVersion: meta.installedVersion || '', + updateAvailable: !!meta.updateAvailable, + crossBranchUpdateSuggested: !!meta.crossBranchUpdateSuggested, + versions: vers, + latestAvailable: meta.latestAvailable || '', + latestOverall: meta.latestOverall || '', + rabbitmqVersionsHint: meta.rabbitmqVersionsHint || '' + }; + }); + } catch (ignore) { + /* keep bare apps list */ + } + })(); + $scope.selectedVersion = 'latest'; + /** Row highlight uses $index so only one row looks selected (avoids sticky :focus / repeater quirks). */ + $scope.selectedVersionRowIndex = 0; + $scope.selectedVersions = ['latest']; + + $scope.recalcSelectedVersionRowIndex = function () { + var list = $scope.selectedVersions || []; + var sel = $scope.selectedVersion; + var i = list.indexOf(sel); + if (i < 0) { + var normSel = normalizeVersionToken(sel); + if (normSel) { + for (var j = 0; j < list.length; j += 1) { + if (normalizeVersionToken(list[j]) === normSel) { + i = j; + $scope.selectedVersion = list[j]; + break; + } + } + } + } + $scope.selectedVersionRowIndex = (i >= 0) ? i : 0; + }; + + $scope.selectManagedAppVersion = function (idx, v, $event) { + var n = (typeof idx === 'number') ? idx : parseInt(idx, 10); + if (!isFinite(n) || n < 0) { + n = 0; + } + $scope.selectedVersionRowIndex = n; + $scope.selectedVersion = v; + if ($event && $event.target && typeof $event.target.blur === 'function') { + $event.target.blur(); + } else if (typeof document !== 'undefined' && document.activeElement && typeof document.activeElement.blur === 'function') { + document.activeElement.blur(); + } + }; + $scope.selectedEsMajor = '8'; + $scope.selectedRabbitmqStream = '4'; + /** RabbitMQ: 4.x is default for new installs (metadata prefetched). Upgrade may require picking stream if version line is unknown. ES major still user-picked before version list loads. */ + $scope.rabbitmqBranchChosen = false; + $scope.esMajorChosen = false; + $scope.confirmAction = false; + $scope.selectedCurrentVersion = ''; + + $scope.chooseRabbitmqStream = function (stream) { + var s = String(stream || '4').trim(); + if (s !== '3' && s !== '4') { + s = '4'; + } + $scope.selectedRabbitmqStream = s; + $scope.rabbitmqBranchChosen = true; + $scope.refreshMeta(); + }; + + $scope.chooseEsMajor = function (major) { + var m = String(major || '8').trim(); + if (m !== '7' && m !== '8' && m !== '9') { + m = '8'; + } + $scope.selectedEsMajor = m; + $scope.esMajorChosen = true; + $scope.refreshMeta(); + }; + + /** + * When the install/upgrade modal is open, re-apply version list from latest applicationMeta. + * (Page-load meta can be empty for ES if dnf was slow; opening the modal must refetch.) + */ + $scope.syncModalVersionLists = function () { + if (!$scope.appName || ($scope.status !== 'Installing' && $scope.status !== 'Upgrading')) { + return; + } + if ($scope.appName !== 'Elasticsearch' && $scope.appName !== 'Redis' && $scope.appName !== 'RabbitMQ') { + return; + } + if ($scope.appName === 'RabbitMQ' && !$scope.rabbitmqBranchChosen) { + $scope.selectedVersions = ['latest']; + $scope.selectedVersion = 'latest'; + $scope.repoShowsOnlyOneStream = false; + $scope.recalcSelectedVersionRowIndex(); + return; + } + if ($scope.appName === 'Elasticsearch' && !$scope.esMajorChosen) { + $scope.selectedVersions = ['latest']; + $scope.selectedVersion = 'latest'; + $scope.repoShowsOnlyOneStream = false; + $scope.recalcSelectedVersionRowIndex(); + return; + } + var meta = $scope.findAppMeta($scope.appName); + var vers = sanitizeVersionsArray((meta && meta.versions) ? meta.versions : []); + $scope.selectedVersions = ['latest'].concat(vers); + var curRaw = (meta && meta.installedVersion) ? meta.installedVersion : ($scope.selectedCurrentVersion || ''); + var cur = normalizeVersionToken(curRaw) || String(curRaw || '').trim(); + if (cur && $scope.selectedVersions.indexOf(cur) === -1) { + var allowCur = true; + if ($scope.appName === 'RabbitMQ') { + allowCur = versionMatchesRabbitmqStream(cur, $scope.selectedRabbitmqStream); + } else if ($scope.appName === 'Elasticsearch') { + allowCur = versionMatchesEsMajor(cur, $scope.selectedEsMajor); + } + if (allowCur) { + $scope.selectedVersions.push(cur); + } + } + if (cur) { + $scope.selectedCurrentVersion = cur; + } + var prevSel = $scope.selectedVersion; + if (prevSel && $scope.selectedVersions.indexOf(prevSel) !== -1) { + $scope.selectedVersion = prevSel; + } else { + $scope.selectedVersion = 'latest'; + } + var realVers = ($scope.selectedVersions || []).filter(function (v) { + return v && v !== 'latest'; + }); + $scope.repoShowsOnlyOneStream = ($scope.status === 'Upgrading' && realVers.length <= 1); + $scope.recalcSelectedVersionRowIndex(); + }; + + $scope.refreshMeta = function () { + $scope.appsMetaRefreshing = true; + var url = "/manageservices/applicationMeta"; + var data = { + esMajor: $scope.selectedEsMajor, + rabbitmqStream: $scope.selectedRabbitmqStream + }; + var config = { + headers: { + 'Content-Type': 'application/json;charset=UTF-8', + 'X-CSRFToken': getCookie('csrftoken') + }, + transformRequest: function (payload) { + return angular.toJson(payload); + } + }; + + return $http.post(url, data, config).then(function (response) { + $scope.appsMetaRefreshing = false; + var payload = response.data; + var ok = payload && (payload.status === 1 || payload.status === '1'); + if (ok) { + var appMap = {}; + (payload.apps || []).forEach(function (app) { + appMap[app.name] = app; + }); + var esMetaResp = appMap['Elasticsearch']; + var rmqMetaResp = appMap['RabbitMQ']; + var respEsMaj = String(esMetaResp && esMetaResp.major != null ? esMetaResp.major : '').trim(); + if (respEsMaj && respEsMaj !== String($scope.selectedEsMajor || '8').trim()) { + return; + } + var respRmqStream = String(rmqMetaResp && rmqMetaResp.rabbitmqStream != null ? rmqMetaResp.rabbitmqStream : '').trim(); + if (respRmqStream && respRmqStream !== String($scope.selectedRabbitmqStream || '4').trim()) { + return; + } + $scope.apps = $scope.apps.map(function (baseApp) { + var meta = appMap[baseApp.name] || {}; + var vers = meta.versions; + if (!angular.isArray(vers)) { + vers = []; + } + vers = sanitizeVersionsArray(vers); + return { + name: baseApp.name, + image: baseApp.image, + installed: !!meta.installed, + installedVersion: meta.installedVersion || '', + updateAvailable: !!meta.updateAvailable, + crossBranchUpdateSuggested: !!meta.crossBranchUpdateSuggested, + versions: vers, + latestAvailable: meta.latestAvailable || '', + latestOverall: meta.latestOverall || '', + rabbitmqVersionsHint: meta.rabbitmqVersionsHint || '' + }; + }); + $scope.syncModalVersionLists(); + } else { + new PNotify({ + title: 'Operation Failed!', + text: (payload && (payload.error_message || payload.errorMessage)) || 'Could not load application metadata.', + type: 'error' + }); + } + }, function () { + $scope.appsMetaRefreshing = false; + new PNotify({ + title: 'Operation Failed!', + text: 'Could not connect to server, please refresh this page', + type: 'error' + }); + }); + }; + + $scope.prepareAction = function (service, status, bootstrapInstalledVersion) { + if (bootstrapInstalledVersion === undefined || bootstrapInstalledVersion === null) { + bootstrapInstalledVersion = ''; + } else { + bootstrapInstalledVersion = String(bootstrapInstalledVersion).trim(); + } + $scope.status = status; + $scope.appName = service.name; + $scope.confirmAction = false; + var effectiveInstalled = (service.installedVersion || bootstrapInstalledVersion || '').trim(); + $scope.selectedCurrentVersion = effectiveInstalled; + + if (service.name === 'RabbitMQ') { + if (effectiveInstalled && /^4\./.test(effectiveInstalled)) { + $scope.selectedRabbitmqStream = '4'; + } else if (effectiveInstalled && /^3\./.test(effectiveInstalled)) { + $scope.selectedRabbitmqStream = '3'; + } else if (status === 'Installing') { + $scope.selectedRabbitmqStream = '4'; + } + if (status === 'Upgrading' && effectiveInstalled) { + if (/^4\./.test(effectiveInstalled) || /^3\./.test(effectiveInstalled)) { + $scope.rabbitmqBranchChosen = true; + } + } + } + + if (service.name === 'Elasticsearch' && effectiveInstalled) { + var iv = effectiveInstalled; + if (/^9\./.test(iv)) { + $scope.selectedEsMajor = '9'; + } else if (/^8\./.test(iv)) { + $scope.selectedEsMajor = '8'; + } else if (/^7\./.test(iv)) { + $scope.selectedEsMajor = '7'; + } + } + + $scope.selectedVersions = ['latest']; + // RabbitMQ upgrade: bootstrap meta is often stream 4; stream follows installed line — do not + // reuse service.versions until refreshMeta returns for selectedRabbitmqStream (avoids mismatched list). + var deferVersionList = (service.name === 'RabbitMQ' && (!$scope.rabbitmqBranchChosen || status === 'Upgrading')) + || (service.name === 'Elasticsearch' && !$scope.esMajorChosen); + if (!deferVersionList) { + var svcVers = sanitizeVersionsArray(service.versions || []); + if (svcVers.length > 0) { + $scope.selectedVersions = ['latest'].concat(svcVers); + } + var curPick = normalizeVersionToken(effectiveInstalled) || effectiveInstalled; + if (curPick && $scope.selectedVersions.indexOf(curPick) === -1) { + $scope.selectedVersions.push(curPick); + } + } + $scope.selectedVersion = 'latest'; + $scope.requestData = ''; + + var realVers = ($scope.selectedVersions || []).filter(function (v) { + return v && v !== 'latest'; + }); + if (deferVersionList) { + $scope.repoShowsOnlyOneStream = false; + } else { + $scope.repoShowsOnlyOneStream = ($scope.status === 'Upgrading' && realVers.length <= 1); + } + $scope.recalcSelectedVersionRowIndex(); + }; + + $scope.findAppMeta = function (appName) { + var found = null; + ($scope.apps || []).forEach(function (item) { + if (item.name === appName) { + found = item; + } + }); + return found || {}; + }; + + $scope.prepareActionByName = function (appName, status, bootstrapInstalledVersion) { + var meta = $scope.findAppMeta(appName); + if (!meta.name) { + meta = {name: appName, versions: []}; + } + var mver = meta.versions; + if (!angular.isArray(mver)) { + mver = []; + } + var merged = { + name: meta.name, + image: meta.image, + installed: meta.installed, + installedVersion: meta.installedVersion || '', + versions: mver + }; + $scope.prepareAction(merged, status, bootstrapInstalledVersion); + }; + + /** + * Prepare scope then show modal (do not use data-toggle + ng-click — Bootstrap can + * open the dialog before Angular runs ng-click, leaving status/appName unset). + * For managed apps, wait for applicationMeta so version lists and installedVersion are correct (upgrade vs downgrade). + */ + $scope.openApplicationsModal = function (appName, status, bootstrapInstalledVersion) { + var needMeta = (appName === 'RabbitMQ' || appName === 'Elasticsearch' || appName === 'Redis'); + var showModal = function () { + if (typeof window.jQuery !== 'undefined' && jQuery('#settings').modal) { + jQuery('#settings').modal('show'); + } + }; + if (needMeta) { + if (appName === 'RabbitMQ') { + if (status === 'Installing') { + $scope.selectedRabbitmqStream = '4'; + $scope.rabbitmqBranchChosen = true; + } else { + $scope.rabbitmqBranchChosen = false; + } + } + if (appName === 'Elasticsearch') { + $scope.esMajorChosen = false; + } + // RabbitMQ install: default 4.x stream and prefetch metadata. Upgrade: pick stream from installed version. + // Elasticsearch: still wait for user major. Redis refreshes on open. + $scope.appName = appName; + $scope.status = status; + $scope.prepareActionByName(appName, status, bootstrapInstalledVersion); + showModal(); + if (appName === 'Redis' || (appName === 'RabbitMQ' && $scope.rabbitmqBranchChosen)) { + $timeout(function () { + $scope.refreshMeta(); + }, 0); + } + } else { + $scope.prepareActionByName(appName, status, bootstrapInstalledVersion); + showModal(); + } + }; + + $scope.runAction = function () { + var appName = $scope.appName; + var status = $scope.status; + if (!appName || !status) { + return; + } + if ((status === 'Removing' || status === 'Upgrading') && !$scope.confirmAction) { + new PNotify({ + title: 'Confirmation Required', + text: 'Please confirm this action before proceeding.', + type: 'warning' + }); + return; + } + if (appName === 'RabbitMQ' && (status === 'Installing' || status === 'Upgrading') && !$scope.rabbitmqBranchChosen) { + new PNotify({ + title: 'Stream required', + text: 'Choose RabbitMQ 4.x or 3.x above to load versions for that line.', + type: 'warning' + }); + return; + } + if (appName === 'Elasticsearch' && (status === 'Installing' || status === 'Upgrading') && !$scope.esMajorChosen) { + new PNotify({ + title: 'Major version required', + text: 'Choose Elasticsearch major (7, 8, or 9) above to load versions for that line.', + type: 'warning' + }); + return; + } $scope.status = status; $scope.appName = appName; @@ -441,7 +936,11 @@ app.controller('manageApplications', function ($scope, $http, $timeout, $window) var data = { appName: appName, - status: status + status: status, + version: $scope.selectedVersion || 'latest', + esMajor: $scope.selectedEsMajor || '8', + rabbitmqStream: $scope.selectedRabbitmqStream || '4', + confirmAction: $scope.confirmAction === true }; var config = { @@ -524,6 +1023,18 @@ app.controller('manageApplications', function ($scope, $http, $timeout, $window) } + // Do not fetch package metadata on page load; it can block workers under DNF load. + // Metadata is fetched on-demand when opening install/version-change modals. + + if (typeof window.jQuery !== 'undefined' && jQuery.fn.on) { + jQuery('#settings').on('hidden.bs.modal', function () { + $scope.$evalAsync(function () { + $scope.rabbitmqBranchChosen = false; + $scope.esMajorChosen = false; + }); + }); + } + }); /* Java script code */ \ No newline at end of file diff --git a/userManagment/static/userManagment/userManagment.js b/userManagment/static/userManagment/userManagment.js index 3b90ed8ea..e0fcfee2a 100644 --- a/userManagment/static/userManagment/userManagment.js +++ b/userManagment/static/userManagment/userManagment.js @@ -848,9 +848,20 @@ app.controller('createACLCTRL', function ($scope, $http) { var url = "/users/createACLFunc"; + var aclNameTrimmed = ($scope.aclName !== undefined && $scope.aclName !== null) ? String($scope.aclName).trim() : ''; + if (!aclNameTrimmed) { + $scope.aclLoading = true; + safePNotify({ + title: 'Error!', + text: 'Please enter a name for this ACL.', + type: 'error' + }); + return; + } + var data = { - aclName: $scope.aclName, + aclName: aclNameTrimmed, makeAdmin: $scope.makeAdmin, // @@ -943,10 +954,10 @@ app.controller('createACLCTRL', function ($scope, $http) { type: 'success' }); } else { - + var errText = (response.data && (response.data.errorMessage || response.data.error_message)) ? (response.data.errorMessage || response.data.error_message) : 'Unknown error'; safePNotify({ title: 'Error!', - text: response.data.errorMessage, + text: errText, type: 'error' }); diff --git a/userManagment/views.py b/userManagment/views.py index 2f3406fe5..8283ad96a 100644 --- a/userManagment/views.py +++ b/userManagment/views.py @@ -3,6 +3,7 @@ from django.shortcuts import render, redirect from django.http import HttpResponse +from django.utils.translation import gettext as _ from django.db import models from django.db.utils import IntegrityError, ProgrammingError, OperationalError from django.views.decorators.csrf import ensure_csrf_cookie @@ -765,9 +766,17 @@ def createACLFunc(request): if currentACL['admin'] == 1: data = json.loads(request.body) + acl_name_raw = data.get('aclName', '') or '' + acl_name = str(acl_name_raw).strip() + if not acl_name: + msg = str(_('Please enter a name for this ACL.')) + err_body = {'status': 0, 'error_message': msg, 'errorMessage': msg} + return HttpResponse(json.dumps(err_body), content_type='application/json') + data['aclName'] = acl_name + ## Version Management - if data['makeAdmin']: + if data.get('makeAdmin'): data['adminStatus'] = 1 else: data['adminStatus'] = 0 diff --git a/websiteFunctions/templates/websiteFunctions/securityManagement.html b/websiteFunctions/templates/websiteFunctions/securityManagement.html deleted file mode 100644 index 38cfd6423..000000000 --- a/websiteFunctions/templates/websiteFunctions/securityManagement.html +++ /dev/null @@ -1,322 +0,0 @@ -{% extends "baseTemplate/index.html" %} -{% load static %} - -{% block title %} -Security Management - CyberPanel -{% endblock %} - -{% block content %} -
-
-
-
-
-

- - Security Management -

-
-
- -
-
-
-
Security Alerts
-

Monitor and manage security threats detected by the system.

- -
-
-
- - -
-
-
-
-

Recent Security Alerts

-
-
-
- -
- Loading security alerts... -
-
-
-
-
-
- - -
-
-
-
-

Blocked IP Addresses

- -
-
-
- - - - - - - - - - - - - - -
IP AddressBlocked AtReasonActions
- Loading... -
-
-
-
-
-
- - -
-
-
-
-

Manual IP Blocking

-
-
-
-
-
-
- - -
-
-
-
- - -
-
-
- -
-
-
-
-
-
-
-
-
-
- - -{% endblock %} diff --git a/websiteFunctions/urls.py b/websiteFunctions/urls.py index e353f5de7..df7610291 100644 --- a/websiteFunctions/urls.py +++ b/websiteFunctions/urls.py @@ -215,13 +215,6 @@ urlpatterns = [ path('getBandwidthResetLogs', views.getBandwidthResetLogs, name='getBandwidthResetLogs'), path('scheduleBandwidthReset', views.scheduleBandwidthReset, name='scheduleBandwidthReset'), - # Security Management - path('securityManagement', views.securityManagementPage, name='securityManagementPage'), - - # IP Blocking - path('blockIPAddress', views.blockIPAddress, name='blockIPAddress'), - path('unblockIPAddress', views.unblockIPAddress, name='unblockIPAddress'), - path('getBlockedIPs', views.getBlockedIPs, name='getBlockedIPs'), path('checkIPStatus', views.checkIPStatus, name='checkIPStatus'), # Catch all for domains (must be last) diff --git a/websiteFunctions/views.py b/websiteFunctions/views.py index 98b7a0b30..91355b47c 100644 --- a/websiteFunctions/views.py +++ b/websiteFunctions/views.py @@ -2242,15 +2242,6 @@ def bandwidthManagementPage(request): except KeyError: return redirect(loadLoginPage) -def securityManagementPage(request): - """Render the Security Management page.""" - try: - userID = request.session['userID'] - proc = httpProc(request, 'websiteFunctions/securityManagement.html', {}, 'admin') - return proc.render() - except KeyError: - return redirect(loadLoginPage) - def getFTPQuotaStatus(request): try: userID = request.session['userID'] @@ -2308,31 +2299,6 @@ def scheduleBandwidthReset(request): except KeyError: return redirect(loadLoginPage) -# IP Blocking Views -def blockIPAddress(request): - try: - userID = request.session['userID'] - wm = WebsiteManager() - return wm.blockIPAddress(userID, request.POST) - except KeyError: - return redirect(loadLoginPage) - -def unblockIPAddress(request): - try: - userID = request.session['userID'] - wm = WebsiteManager() - return wm.unblockIPAddress(userID, request.POST) - except KeyError: - return redirect(loadLoginPage) - -def getBlockedIPs(request): - try: - userID = request.session['userID'] - wm = WebsiteManager() - return wm.getBlockedIPs(userID, request.POST) - except KeyError: - return redirect(loadLoginPage) - def checkIPStatus(request): try: userID = request.session['userID'] diff --git a/websiteFunctions/website.py b/websiteFunctions/website.py index 4039575d9..c976ba90e 100644 --- a/websiteFunctions/website.py +++ b/websiteFunctions/website.py @@ -9322,141 +9322,6 @@ StrictHostKeyChecking no json_data = json.dumps(data_ret) return HttpResponse(json_data) - def blockIPAddress(self, userID=None, data=None): - """ - Block an IP address - """ - try: - currentACL = ACLManager.loadedACL(userID) - admin = Administrator.objects.get(pk=userID) - - # Check if user has permission - if not (currentACL.get('admin', 0) == 1): - return ACLManager.loadErrorJson('status', 0) - - ip_address = data.get('ip_address') - reason = data.get('reason', 'Manual block via CyberPanel') - - if not ip_address: - data_ret = { - 'status': 0, - 'message': 'IP address is required' - } - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - - # Import firewall utilities - from plogical.firewallUtilities import FirewallUtilities - - # Block the IP - success, message = FirewallUtilities.blockIP(ip_address, reason) - - if success: - data_ret = { - 'status': 1, - 'message': message - } - else: - data_ret = { - 'status': 0, - 'message': message - } - - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - - except Exception as e: - data_ret = { - 'status': 0, - 'message': f'Error blocking IP: {str(e)}' - } - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - - def unblockIPAddress(self, userID=None, data=None): - """ - Unblock an IP address - """ - try: - currentACL = ACLManager.loadedACL(userID) - admin = Administrator.objects.get(pk=userID) - - # Check if user has permission - if not (currentACL.get('admin', 0) == 1): - return ACLManager.loadErrorJson('status', 0) - - ip_address = data.get('ip_address') - - if not ip_address: - data_ret = { - 'status': 0, - 'message': 'IP address is required' - } - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - - # Import firewall utilities - from plogical.firewallUtilities import FirewallUtilities - - # Unblock the IP - success, message = FirewallUtilities.unblockIP(ip_address) - - if success: - data_ret = { - 'status': 1, - 'message': message - } - else: - data_ret = { - 'status': 0, - 'message': message - } - - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - - except Exception as e: - data_ret = { - 'status': 0, - 'message': f'Error unblocking IP: {str(e)}' - } - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - - def getBlockedIPs(self, userID=None, data=None): - """ - Get list of blocked IP addresses - """ - try: - currentACL = ACLManager.loadedACL(userID) - admin = Administrator.objects.get(pk=userID) - - # Check if user has permission - if not (currentACL.get('admin', 0) == 1): - return ACLManager.loadErrorJson('status', 0) - - # Import firewall utilities - from plogical.firewallUtilities import FirewallUtilities - - # Get blocked IPs - blocked_ips = FirewallUtilities.getBlockedIPs() - - data_ret = { - 'status': 1, - 'blocked_ips': blocked_ips - } - - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - - except Exception as e: - data_ret = { - 'status': 0, - 'message': f'Error getting blocked IPs: {str(e)}' - } - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - def checkIPStatus(self, userID=None, data=None): """ Check if an IP is blocked