From 8f7cb14305389d2f992b811cd6f560cc6588fe20 Mon Sep 17 00:00:00 2001 From: Disassembler Date: Sun, 4 Nov 2018 19:49:15 +0100 Subject: [PATCH] Implement global queue, improve failure resiliency --- usr/bin/vmmgr | 10 +- usr/lib/python3.6/vmmgr/__init__.py | 143 ++++-------- usr/lib/python3.6/vmmgr/actionqueue.py | 69 ++++++ usr/lib/python3.6/vmmgr/appmgr.py | 169 +++++++++------ usr/lib/python3.6/vmmgr/config.py | 5 +- usr/lib/python3.6/vmmgr/tools.py | 18 +- usr/lib/python3.6/vmmgr/wsgiapp.py | 205 +++++++++--------- usr/lib/python3.6/vmmgr/wsgilang.py | 14 ++ usr/share/vmmgr/static/js/admin.js | 62 +++--- usr/share/vmmgr/templates/404.html | 3 - usr/share/vmmgr/templates/502.html | 5 +- usr/share/vmmgr/templates/503.html | 12 + usr/share/vmmgr/templates/setup-apps-row.html | 23 -- .../vmmgr/templates/setup-apps-table.html | 13 ++ usr/share/vmmgr/templates/setup-apps.html | 9 +- usr/share/vmmgr/templates/setup-host.html | 2 +- 16 files changed, 424 insertions(+), 338 deletions(-) create mode 100644 usr/lib/python3.6/vmmgr/actionqueue.py create mode 100644 usr/share/vmmgr/templates/503.html delete mode 100644 usr/share/vmmgr/templates/setup-apps-row.html create mode 100644 usr/share/vmmgr/templates/setup-apps-table.html diff --git a/usr/bin/vmmgr b/usr/bin/vmmgr index 9e772b1..c5340a3 100755 --- a/usr/bin/vmmgr +++ b/usr/bin/vmmgr @@ -4,15 +4,16 @@ import argparse import sys from vmmgr import VMMgr +from vmmgr.config import Config parser = argparse.ArgumentParser(description='VM application manager') subparsers = parser.add_subparsers() parser_register_app = subparsers.add_parser('register-app') parser_register_app.set_defaults(action='register-app') -parser_register_app.add_argument('app', help='Application name') -parser_register_app.add_argument('login', help='Administrative login') -parser_register_app.add_argument('password', help='Administrative password') +parser_register_app.add_argument('app') +parser_register_app.add_argument('login', nargs='?') +parser_register_app.add_argument('password', nargs='?') parser_rebuild_issue = subparsers.add_parser('rebuild-issue') parser_rebuild_issue.set_defaults(action='rebuild-issue') @@ -38,7 +39,8 @@ parser_unregister_proxy.set_defaults(action='unregister-proxy') parser_unregister_proxy.add_argument('app', help='Application name') args = parser.parse_args() -mgr = VMMgr() +conf = Config() +mgr = VMMgr(conf) if args.action == 'register-app': # Used by app install scripts mgr.register_app(args.app, args.login, args.password) diff --git a/usr/lib/python3.6/vmmgr/__init__.py b/usr/lib/python3.6/vmmgr/__init__.py index 05accb1..d6b1647 100644 --- a/usr/lib/python3.6/vmmgr/__init__.py +++ b/usr/lib/python3.6/vmmgr/__init__.py @@ -78,6 +78,29 @@ server {{ return 200 "vm-pong"; }} }} + +server {{ + listen [::]:{port} ssl http2; + server_name ~^(.*)\.{domain_esc}$; + + location / {{ + return 503; + }} + + location /static {{ + root /usr/share/vmmgr; + }} + + error_page 503 /503.html; + location = /503.html {{ + root /usr/share/vmmgr/templates; + }} + + location = /vm-ping {{ + add_header Content-Type text/plain; + return 200 "vm-pong"; + }} +}} ''' ISSUE_TEMPLATE = ''' @@ -121,90 +144,21 @@ subjectAltName=DNS:{domain},DNS:*.{domain}" ''' class VMMgr: - def __init__(self): + def __init__(self, conf): # Load JSON configuration - self.conf = Config() - self.domain = self.conf['host']['domain'] - self.port = self.conf['host']['port'] + self.conf = conf + self.domain = conf['host']['domain'] + self.port = conf['host']['port'] def register_app(self, app, login, password): - # Register a newly installed application and update login and password + # Write a file with credentials of a newly installed application which + # will be picked up by thread performing the installation after the install script finishes if app not in self.conf['packages']: raise validator.InvalidValueException('app', app) - self.conf['apps'][app] = { - 'title': metadata['title'], - 'host': metadata['host'], - 'login': login if login else 'N/A', - 'password': password if password else 'N/A', - 'visible': False - } - self.conf.save() - - def show_tiles(self, app): - # Update visibility for the app in the configuration - if app not in self.conf['apps']: - raise validator.InvalidValueException('app', app) - self.conf['apps'][app]['visible'] = True - self.conf.save() - - def hide_tiles(self, app): - # Update visibility for the app in the configuration - if app not in self.conf['apps']: - raise validator.InvalidValueException('app', app) - self.conf['apps'][app]['visible'] = False - self.conf.save() - - def start_app(self, app): - # Start the actual app service - if app not in self.conf['apps']: - raise validator.InvalidValueException('app', app) - tools.start_service(app) - - def stop_app(self, app): - # Stop the actual app service - if app not in self.conf['apps']: - raise validator.InvalidValueException('app', app) - tools.stop_service(app) - # Stop the app service's dependencies if they are not used by another running app - deps = self.build_deps_tree() - for dep in self.get_app_deps(app): - if not any([tools.is_service_started(d) for d in deps[dep]]): - tools.stop_service(dep) - - def build_deps_tree(self): - # Fisrt, build a dictionary of {app: [needs]} - needs = {} - for app in self.conf['apps']: - needs[app] = self.get_app_deps(app) - # Then reverse it to {need: [apps]} - deps = {} - for app, need in needs.items(): - for n in need: - deps.setdefault(n, []).append(app) - return deps - - def get_app_deps(self, app): - # Get "needs" line from init script and split it to list - try: - with open(os.path.join('/etc/init.d', app), 'r') as f: - for line in f.readlines(): - if line.strip().startswith('need'): - return line.split()[1:] - except: - pass - return [] - - def enable_autostart(self, app): - # Add the app to OpenRC default runlevel - if app not in self.conf['apps']: - raise validator.InvalidValueException('app', app) - subprocess.run(['/sbin/rc-update', 'add', app]) - - def disable_autostart(self, app): - # Remove the app from OpenRC default runlevel - if app not in self.conf['apps']: - raise validator.InvalidValueException('app', app) - subprocess.run(['/sbin/rc-update', 'del', app]) + login = login if login else 'N/A' + password = password if password else 'N/A' + with open('/tmp/{}.credentials'.format(app), 'w') as f: + f.write('{}\n{}'.format(login, password)) def prepare_container(self): # Extract the variables from values given via lxc.hook.pre-start hook @@ -242,16 +196,12 @@ class VMMgr: def register_proxy(self, app): # Setup proxy configuration and reload nginx - if app not in self.conf['apps']: - raise validator.InvalidValueException('app', app) with open(os.path.join(NGINX_DIR, '{}.conf'.format(app)), 'w') as f: - f.write(NGINX_TEMPLATE.format(app=app, host=self.conf['apps'][app]['host'], domain=self.domain, port=self.port)) + f.write(NGINX_TEMPLATE.format(app=app, host=self.conf['packages'][app]['host'], domain=self.domain, port=self.port)) tools.reload_nginx() def unregister_proxy(self, app): # Remove proxy configuration and reload nginx - if app not in self.conf['apps']: - raise validator.InvalidValueException('app', app) os.unlink(os.path.join(NGINX_DIR, '{}.conf'.format(app))) tools.reload_nginx() @@ -265,7 +215,7 @@ class VMMgr: self.port = self.conf['host']['port'] = port self.conf.save() # Restart all apps to trigger configuration refresh - for app in self.conf['apps']: + for app in self.conf['apps'].copy(): if tools.is_service_started(app): tools.restart_service(app) # Rebuild and restart nginx if it was requested. @@ -274,7 +224,7 @@ class VMMgr: def rebuild_nginx(self): # Rebuild nginx config for the portal app. Web interface calls tools.restart_nginx() in WSGI close handler with open(os.path.join(NGINX_DIR, 'default.conf'), 'w') as f: - f.write(NGINX_DEFAULT_TEMPLATE.format(port=self.port)) + f.write(NGINX_DEFAULT_TEMPLATE.format(port=self.port, domain_esc=self.domain.replace('.', '\.'))) def rebuild_issue(self): # Compile the URLs displayed in terminal banner @@ -287,23 +237,6 @@ class VMMgr: with open(ISSUE_FILE, 'w') as f: f.write(ISSUE_TEMPLATE.format(url=tools.compile_url(self.domain, self.port), ip=tools.compile_url(ip, self.port))) - def update_common(self, email, gmaps_api_key): - # Update common configuration values - if email != None: - # Update email - if not validator.is_valid_email(email): - raise validator.InvalidValueException('email', email) - self.conf['common']['email'] = email - if gmaps_api_key != None: - # Update Google Maps API key - self.conf['common']['gmaps-api-key'] = gmaps_api_key - # Save config to file - self.conf.save() - for app in self.conf['apps']: - # Restart currently running apps in order to update their config - if tools.is_service_started(app): - tools.restart_service(app) - def update_password(self, oldpassword, newpassword): # Update LUKS password and adminpwd for WSGI application input = '{}\n{}'.format(oldpassword, newpassword).encode() @@ -332,8 +265,8 @@ class VMMgr: # Compile an acme.sh command for certificate requisition only if the certificate hasn't been requested before if not os.path.exists(os.path.join('/etc/acme.sh.d', self.domain)): cmd = ['/usr/bin/acme.sh', '--issue', '-d', self.domain] - for app in self.conf['apps']: - cmd += ['-d', '{}.{}'.format(self.conf['apps'][app]['host'], self.domain)] + for app in self.conf['apps'].copy(): + cmd += ['-d', '{}.{}'.format(self.conf['packages'][app]['host'], self.domain)] cmd += ['-w', '/etc/acme.sh.d'] # Request the certificate subprocess.run(cmd, check=True) diff --git a/usr/lib/python3.6/vmmgr/actionqueue.py b/usr/lib/python3.6/vmmgr/actionqueue.py new file mode 100644 index 0000000..da238eb --- /dev/null +++ b/usr/lib/python3.6/vmmgr/actionqueue.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- + +from collections import deque +from threading import Lock + +class ActionItem: + def __init__(self, key, action): + self.key = key + self.action = action + self.started = False + self.data = None + +class ActionQueue: + def __init__(self): + self.actions = {} + # Priority 0 = restart/shutdown, 1 = config update, 2 = apps actions + self.queue = deque() + self.lock = Lock() + self.is_running = False + + def get_actions(self): + # Return copy of actions, so they can be traversed without state changes + with self.lock: + return self.actions.copy() + + def enqueue_action(self, key, action): + # Enqueue action + with self.lock: + if key in self.actions: + # If the key alredy has a pending action, reject any other actions + return + item = ActionItem(key, action) + self.actions[key] = item + self.queue.append(item) + + def process_actions(self): + # Main method for deferred queue processing called by WSGI close handler + with self.lock: + # If the queue is being processesd by another thread, allow this thread to be terminated + if self.is_running: + return + while True: + with self.lock: + # Try to get an item from queue + item = None + if self.queue: + item = self.queue.popleft() + # If there are no more queued items, unset the processing flag and allow the thread to be terminated + if not item: + self.is_running = False + return + # If there is an item to be processed, set processing flags and exit the lock + self.is_running = True + item.started = True + try: + # Call the method passed in item.action with the whole item as parameter + item.action(item) + # If the action finished without errors, restore nominal state by deleting the item from action list + self.clear_action(item.key) + except BaseException as e: + # If the action failed, store the exception and leave it in the list form manual clearance + with self.lock: + item.data = e + + def clear_action(self, key): + # Restore nominal state by deleting the item from action list + with self.lock: + if key in self.actions: + del self.actions[key] diff --git a/usr/lib/python3.6/vmmgr/appmgr.py b/usr/lib/python3.6/vmmgr/appmgr.py index 78710de..2e68779 100644 --- a/usr/lib/python3.6/vmmgr/appmgr.py +++ b/usr/lib/python3.6/vmmgr/appmgr.py @@ -6,30 +6,18 @@ import os import requests import shutil import subprocess -import time -import uuid from cryptography.exceptions import InvalidSignature from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.serialization import load_pem_public_key -from threading import Lock from . import tools PUB_FILE = '/etc/vmmgr/packages.pub' LXC_ROOT = '/var/lib/lxc' -class ActionItem: - def __init__(self, action, app): - self.timestamp = int(time.time()) - self.action = action - self.app = app - self.started = False - self.finished = False - self.data = None - class InstallItem: def __init__(self, total): # Stage 0 = download, 1 = deps install, 2 = app install @@ -42,12 +30,9 @@ class InstallItem: return str(min(99, round(self.downloaded / self.total * 100))) class AppMgr: - def __init__(self, vmmgr): - self.vmmgr = vmmgr - self.conf = vmmgr.conf + def __init__(self, conf): + self.conf = conf self.online_packages = {} - self.action_queue = {} - self.lock = Lock() def get_repo_resource(self, url, stream=False): return requests.get('{}/{}'.format(self.conf['repo']['url'], url), auth=(self.conf['repo']['user'], self.conf['repo']['pwd']), timeout=5, stream=stream) @@ -64,48 +49,47 @@ class AppMgr: # Minimze the time when self.online_packages is out of sync self.online_packages = online_packages - def enqueue_action(self, action, app): - # Remove actions older than 1 day - for id,item in self.action_queue.items(): - if item.timestamp < time.time() - 86400: - del self.item[id] - # Enqueue action - id = '{}:{}'.format(app, uuid.uuid4()) - item = ActionItem(action, app) - self.action_queue[id] = item - return id,item - - def get_actions(self, ids): - # Return list of requested actions - result = {} - for id in ids: - result[id] = self.action_queue[id] if id in self.action_queue else None - return result - - def process_action(self, id): - # Main method for deferred queue processing called by WSGI close handler - item = self.action_queue[id] - with self.lock: - item.started = True - try: - # Call the action method inside exclusive lock - getattr(self, item.action)(item) - except BaseException as e: - item.data = e - finally: - item.finished = True - def start_app(self, item): - if not tools.is_service_started(item.app): - self.vmmgr.start_app(item.app) + # Start the actual app service + app = item.key + if app not in self.conf['apps']: + raise validator.InvalidValueException('app', app) + if not tools.is_service_started(app): + tools.start_service(app) def stop_app(self, item): - if tools.is_service_started(item.app): - self.vmmgr.stop_app(item.app) + # Stop the actual app service + app = item.key + if app not in self.conf['apps']: + raise validator.InvalidValueException('app', app) + if tools.is_service_started(app): + tools.stop_service(app) + # Stop the app service's dependencies if they are not used by another running app + deps = self.get_services_deps() + for dep in tools.get_service_deps(app): + if not any([tools.is_service_started(d) for d in deps[dep]]): + tools.stop_service(dep) + + def update_app_visibility(self, app, visible): + # Update visibility for the app in the configuration + if app not in self.conf['apps']: + raise validator.InvalidValueException('app', app) + self.conf['apps'][app]['visible'] = visible + self.conf.save() + + def update_app_autostart(self, app, enabled): + # Add/remove the app to OpenRC default runlevel + if app not in self.conf['apps']: + raise validator.InvalidValueException('app', app) + subprocess.run(['/sbin/rc-update', 'add' if enabled else 'del', app]) def install_app(self, item): # Main installation function. Wrapper for download, registration and install script - deps = [d for d in self.get_install_deps(item.app) if d not in self.conf['packages']] + app = item.key + # Clean packages which previously failed to install + self.clean_pending_packages() + # Get all packages on which the app depends and which have not been installed yet + deps = [d for d in self.get_install_deps(app) if d not in self.conf['packages'] or 'pending' in self.conf['packages'][d]] item.data = InstallItem(sum(self.online_packages[d]['size'] for d in deps)) for dep in deps: self.download_package(dep, item.data) @@ -118,13 +102,15 @@ class AppMgr: self.run_uninstall_script(dep) self.register_package(dep) self.run_install_script(dep) + self.finalize_installation(dep) def uninstall_app(self, item): # Main uninstallation function. Wrapper for uninstall script, filesystem purge and unregistration + app = item.key self.stop_app(item) - if tools.is_service_autostarted(item.app): - self.vmmgr.disable_autostart(item.app) - deps = self.get_install_deps(item.app, False)[::-1] + if tools.is_service_autostarted(app): + self.update_app_autostart(app, False) + deps = self.get_install_deps(app, False)[::-1] for dep in deps: if dep not in self.get_uninstall_deps(): self.run_uninstall_script(dep) @@ -138,13 +124,13 @@ class AppMgr: for chunk in r.iter_content(chunk_size=65536): if chunk: installitem.downloaded += f.write(chunk) - - def unpack_package(self, name): - tmp_archive = '/tmp/{}.tar.xz'.format(name) # Verify hash if self.online_packages[name]['sha512'] != hash_file(tmp_archive): raise InvalidSignature(name) - # Unpack + + def unpack_package(self, name): + # Unpack archive + tmp_archive = '/tmp/{}.tar.xz'.format(name) subprocess.run(['tar', 'xJf', tmp_archive], cwd='/', check=True) os.unlink(tmp_archive) @@ -165,14 +151,40 @@ class AppMgr: # Registers a package in local configuration metadata = self.online_packages[name].copy() del metadata['sha512'] + del metadata['size'] + metadata['pending'] = True self.conf['packages'][name] = metadata self.conf.save() def unregister_package(self, name): # Removes a package from local configuration - del self.conf['packages'][name] if name in self.conf['apps']: del self.conf['apps'][name] + del self.conf['packages'][name] + self.conf.save() + + def finalize_installation(self, name): + # If the install script called vmmgr register-app, perform the app registration + # This can't be done directly from install script due to possible race conditions + cred_file = '/tmp/{}.credentials'.format(name) + if os.path.exists(cred_file): + with open(cred_file, 'r') as f: + cred = f.read().splitlines() + os.unlink(cred_file) + self.conf['apps'][name] = { + 'login': cred[0], + 'password': cred[1], + 'visible': False + } + # Finally, mark the package as fully installed + del self.conf['packages'][name]['pending'] + self.conf.save() + + def clean_pending_packages(self): + # Remove registeres packages with pending flag set from previously failed installation + for name in self.conf['packages'].copy(): + if 'pending' in self.conf['packages'][name]: + self.unregister_package(name) self.conf.save() def run_install_script(self, name): @@ -203,11 +215,40 @@ class AppMgr: def get_uninstall_deps(self): # Create reverse dependency tree for all installed packages deps = {} - for pkg in self.conf['packages']: - for d in self.conf['packages'][pkg]['deps']: - deps.setdefault(d, []).append(pkg) + for name in self.conf['packages'].copy(): + for d in self.conf['packages'][name]['deps']: + deps.setdefault(d, []).append(name) return deps + def get_services_deps(self): + # Fisrt, build a dictionary of {app: [needs]} + needs = {} + for app in self.conf['apps'].copy(): + needs[app] = tools.get_service_deps(app) + # Then reverse it to {need: [apps]} + deps = {} + for app, need in needs.items(): + for n in need: + deps.setdefault(n, []).append(app) + return deps + + def update_common_settings(self, email, gmaps_api_key): + # Update common configuration values + if email != None: + # Update email + if not validator.is_valid_email(email): + raise validator.InvalidValueException('email', email) + self.conf['common']['email'] = email + if gmaps_api_key != None: + # Update Google Maps API key + self.conf['common']['gmaps-api-key'] = gmaps_api_key + # Save config to file + self.conf.save() + for app in self.conf['apps'].copy(): + # Restart currently running apps in order to update their config + if tools.is_service_started(app): + tools.restart_service(app) + def hash_file(file_path): sha512 = hashlib.sha512() with open(file_path, 'rb') as f: diff --git a/usr/lib/python3.6/vmmgr/config.py b/usr/lib/python3.6/vmmgr/config.py index 5d43ab0..e520f38 100644 --- a/usr/lib/python3.6/vmmgr/config.py +++ b/usr/lib/python3.6/vmmgr/config.py @@ -4,6 +4,7 @@ import fcntl import json CONF_FILE = '/etc/vmmgr/config.json' +LOCK_FILE = '/var/lock/vmmgr-config.lock' class Config: def __init__(self): @@ -11,14 +12,14 @@ class Config: def load(self): # Load configuration from file. Uses file lock as interprocess mutex - with open('/var/lock/vmmgr-hosts.lock', 'w') as lock: + with open(LOCK_FILE, 'w') as lock: fcntl.lockf(lock, fcntl.LOCK_EX) with open(CONF_FILE, 'r') as f: self.data = json.load(f) def save(self): # Save configuration to a file. Uses file lock as interprocess mutex - with open('/var/lock/vmmgr-hosts.lock', 'w') as lock: + with open(LOCK_FILE, 'w') as lock: fcntl.lockf(lock, fcntl.LOCK_EX) with open(CONF_FILE, 'w') as f: json.dump(self.data, f, sort_keys=True, indent=4) diff --git a/usr/lib/python3.6/vmmgr/tools.py b/usr/lib/python3.6/vmmgr/tools.py index c5ab5bb..d309449 100644 --- a/usr/lib/python3.6/vmmgr/tools.py +++ b/usr/lib/python3.6/vmmgr/tools.py @@ -22,6 +22,7 @@ def get_local_ip(version): # Return first routable IPv4/6 address of the VM (container host) try: output = subprocess.run(['/sbin/ip', 'route', 'get', '1' if version == 4 else '2003::'], check=True, stdout=subprocess.PIPE).stdout.decode().split() + # Get field right after 'src' return output[output.index('src')+1] except: return None @@ -60,6 +61,17 @@ def ping_url(url): except: return False +def get_service_deps(app): + # Get "needs" line from init script and split it to list + try: + with open(os.path.join('/etc/init.d', app), 'r') as f: + for line in f.readlines(): + if line.strip().startswith('need'): + return line.split()[1:] + except: + pass + return [] + def is_service_started(app): # Check OpenRC service status without calling any binary return os.path.exists(os.path.join('/run/openrc/started', app)) @@ -117,17 +129,21 @@ def update_hosts_lease(app, is_request): ip = None with open('/var/lock/vmmgr-hosts.lock', 'w') as lock: fcntl.lockf(lock, fcntl.LOCK_EX) + # Load all existing records with open('/etc/hosts', 'r') as f: leases = [l.strip().split(' ', 1) for l in f] + # If this call is a request for lease, find the first unassigned IP if is_request: - used_ips = [l[0] lor l in leases] + used_ips = [l[0] for l in leases] for i in range(2, 65534): ip = '172.17.{}.{}'. format(i // 256, i % 256) if ip not in used_ips: leases.append([ip, app]) break + # Otherwise it is a release in which case we just delete the record else: leases = [l for l in leases if l[1] != app] + # Write the contents back to the file with open('/etc/hosts', 'w') as f: for lease in leases: f.write('{} {}\n'.format(lease[0], lease[1])) diff --git a/usr/lib/python3.6/vmmgr/wsgiapp.py b/usr/lib/python3.6/vmmgr/wsgiapp.py index 4695247..0417ce9 100644 --- a/usr/lib/python3.6/vmmgr/wsgiapp.py +++ b/usr/lib/python3.6/vmmgr/wsgiapp.py @@ -12,7 +12,9 @@ from jinja2 import Environment, FileSystemLoader from . import VMMgr, CERT_PUB_FILE from . import tools +from .actionqueue import ActionQueue from .appmgr import AppMgr +from .config import Config from .validator import InvalidValueException from .wsgilang import WSGILang from .wsgisession import WSGISession @@ -21,21 +23,20 @@ SESSION_KEY = os.urandom(26) class WSGIApp(object): def __init__(self): - self.vmmgr = VMMgr() - self.appmgr = AppMgr(self.vmmgr) - self.conf = self.vmmgr.conf + self.conf = Config() + self.vmmgr = VMMgr(self.conf) + self.appmgr = AppMgr(self.conf) + self.queue = ActionQueue() + # Clean broken and interrupted installations in case of unclean previous shutdown + self.appmgr.clean_pending_packages() self.jinja_env = Environment(loader=FileSystemLoader('/usr/share/vmmgr/templates'), autoescape=True, lstrip_blocks=True, trim_blocks=True) self.jinja_env.globals.update(is_app_visible=self.is_app_visible) - self.jinja_env.globals.update(is_service_autostarted=tools.is_service_autostarted) - self.jinja_env.globals.update(is_service_started=tools.is_service_started) def __call__(self, environ, start_response): return self.wsgi_app(environ, start_response) def wsgi_app(self, environ, start_response): request = Request(environ) - # Reload config in case it has changed between requests - self.conf.load() # Enhance request request.session = WSGISession(request.cookies, SESSION_KEY) request.session.lang = WSGILang() @@ -52,7 +53,7 @@ class WSGIApp(object): return getattr(self, endpoint)(request, **values) except NotFound as e: # Return custom 404 page - response = self.render_template('404.html', request) + response = self.render_html('404.html', request) response.status_code = 404 return response except HTTPException as e: @@ -81,7 +82,8 @@ class WSGIApp(object): Rule('/start-app', endpoint='start_app_action'), Rule('/stop-app', endpoint='stop_app_action'), Rule('/install-app', endpoint='install_app_action'), - Rule('/get-progress', endpoint='get_progress_action'), + Rule('/get-app-status', endpoint='get_app_status_action'), + Rule('/clear-app-status', endpoint='clear_app_status_action'), Rule('/uninstall-app', endpoint='uninstall_app_action'), Rule('/update-password', endpoint='update_password_action'), Rule('/shutdown-vm', endpoint='shutdown_vm_action'), @@ -98,15 +100,20 @@ class WSGIApp(object): # Enhance context context['conf'] = self.conf context['session'] = request.session + context['lang'] = request.session.lang # Render template - t = self.jinja_env.get_template(template_name) - return Response(t.render(context), mimetype='text/html') + template = self.jinja_env.get_template(template_name) + return template.render(context) + + def render_html(self, template_name, request, **context): + html = self.render_template(template_name, request, **context) + return Response(html, mimetype='text/html') def render_json(self, data): return Response(json.dumps(data), mimetype='application/json') def login_view(self, request, **kwargs): - return self.render_template('login.html', request, redirect=kwargs['redirect']) + return self.render_html('login.html', request, redirect=kwargs['redirect']) def login_action(self, request): password = request.form['password'] @@ -115,7 +122,7 @@ class WSGIApp(object): request.session['admin'] = True return redirect(redir_url) else: - return self.render_template('login.html', request, message=request.session.lang.bad_password()) + return self.render_html('login.html', request, message=request.session.lang.bad_password()) def logout_action(self, request): request.session.reset() @@ -125,8 +132,8 @@ class WSGIApp(object): # Default portal view. host = tools.compile_url(self.conf['host']['domain'], self.conf['host']['port'])[8:] if request.session['admin']: - return self.render_template('portal-admin.html', request, host=host) - return self.render_template('portal-user.html', request, host=host) + return self.render_html('portal-admin.html', request, host=host) + return self.render_html('portal-user.html', request, host=host) def setup_host_view(self, request): # Host setup view. @@ -135,7 +142,7 @@ class WSGIApp(object): in_ipv4 = tools.get_local_ip(4) in_ipv6 = tools.get_local_ip(6) cert_info = tools.get_cert_info(CERT_PUB_FILE) - return self.render_template('setup-host.html', request, ex_ipv4=ex_ipv4, ex_ipv6=ex_ipv6, in_ipv4=in_ipv4, in_ipv6=in_ipv6, cert_info=cert_info) + return self.render_html('setup-host.html', request, ex_ipv4=ex_ipv4, ex_ipv6=ex_ipv6, in_ipv4=in_ipv4, in_ipv6=in_ipv6, cert_info=cert_info) def setup_apps_view(self, request): # Application manager view. @@ -143,60 +150,72 @@ class WSGIApp(object): self.appmgr.fetch_online_packages() except: pass - all_apps = sorted(set([k for k,v in self.appmgr.online_packages.items() if 'host' in v] + list(self.conf['apps'].keys()))) - return self.render_template('setup-apps.html', request, all_apps=all_apps, online_packages=self.appmgr.online_packages) + repo_reachable = bool(self.appmgr.online_packages) + table = self.render_setup_apps_table(request) + return self.render_html('setup-apps.html', request, repo_reachable=repo_reachable, table=table) - def render_setup_apps_row(self, request, app, app_title, item): + def render_setup_apps_table(self, request): lang = request.session.lang - actions = '
' - if item.action == 'start_app': - if not item.started: - status = 'Spouští se (ve frontě)' - elif not item.finished: - status = 'Spouští se' - elif isinstance(item.data, BaseException): - status = '{}'.format(lang.stop_start_error()) + pending_actions = self.queue.get_actions() + actionable_apps = sorted(set([k for k,v in self.appmgr.online_packages.items() if 'host' in v] + list(self.conf['apps'].keys()))) + app_data = {} + for app in actionable_apps: + installed = app in self.conf['apps'] + title = self.conf['packages'][app]['title'] if installed else self.appmgr.online_packages[app]['title'] + visible = self.conf['apps'][app]['visible'] if installed else False + autostarted = tools.is_service_autostarted(app) if installed else False + if app in pending_actions: + item = pending_actions[app] + actions = '
' + if item.action == self.appmgr.start_app: + if not item.started: + status = '{} ({})'.format(lang.status_starting(), lang.status_queued()) + elif isinstance(item.data, BaseException): + status = '{} OK'.format(lang.stop_start_error()) + actions = None + else: + status = lang.status_starting() + elif item.action == self.appmgr.stop_app: + if not item.started: + status = '{} ({})'.format(lang.status_stopping(), lang.status_queued()) + elif isinstance(item.data, BaseException): + status = '{} OK'.format(lang.stop_start_error()) + actions = None + else: + status = lang.status_stopping() + elif item.action == self.appmgr.install_app: + if not item.started: + status = '{} ({})'.format(lang.status_downloading(), lang.status_queued()) + elif isinstance(item.data, BaseException): + status = '{} OK'.format(lang.package_manager_error()) + actions = None + else: + if item.data.stage == 0: + status = '{} ({} %)'.format(lang.status_downloading(), item.data) + elif item.data.stage == 1: + status = lang.status_installing_deps() + else: + status = lang.status_installing() + elif item.action == self.appmgr.uninstall_app: + if not item.started: + status = '{} ({})'.format(lang.status_uninstalling(), lang.status_queued()) + elif isinstance(item.data, BaseException): + status = '{} OK'.format(lang.package_manager_error()) + actions = None + else: + status = lang.status_uninstalling() else: - status = 'Spuštěna' - actions = 'Zastavit' - elif item.action == 'stop_app': - if not item.started: - status = 'Zastavuje se (ve frontě)' - elif not item.finished: - status = 'Zastavuje se' - elif isinstance(item.data, BaseException): - status = '{}'.format(lang.stop_start_error()) - else: - status = 'Zastavena' - actions = 'Spustit, Odinstalovat' - elif item.action == 'install_app': - if not item.started: - status = 'Stahuje se (ve frontě)' - elif not item.finished: - if item.data.stage == 0: - status = 'Stahuje se ({} %)'.format(item.data) - elif item.data.stage == 1: - status = 'Instalují se závislosti' + if not installed: + status = lang.status_not_installed() + actions = '{}'.format(lang.action_install()) + elif tools.is_service_started(app): + status = '{}'.format(lang.status_started()) + actions = '{}'.format(lang.action_stop()) else: - status = 'Instaluje se' - elif isinstance(item.data, BaseException): - status = '{}'.format(lang.package_manager_error()) - else: - status = 'Zastavena' - actions = 'Spustit, Odinstalovat' - elif item.action == 'uninstall_app': - if not item.started: - status = 'Odinstalovává se (ve frontě)' - elif not item.finished: - status = 'Odinstalovává se' - elif isinstance(item.data, BaseException): - status = '{}'.format(lang.package_manager_error()) - else: - status = 'Není nainstalována' - actions = 'Instalovat' - is_error = isinstance(item.data, BaseException) - t = self.jinja_env.get_template('setup-apps-row.html') - return t.render({'conf': self.conf, 'session': request.session, 'app': app, 'app_title': app_title, 'status': status, 'actions': actions, 'is_error': is_error}) + status = '{}'.format(lang.status_stopped()) + actions = '{}, {}'.format(lang.action_start(), lang.action_uninstall()) + app_data[app] = {'title': title, 'visible': visible, 'installed': installed,'autostarted': autostarted, 'status': status, 'actions': actions} + return self.render_template('setup-apps-table.html', request, app_data=app_data) def update_host_action(self, request): # Update domain and port, then restart nginx @@ -219,7 +238,7 @@ class WSGIApp(object): def verify_dns_action(self, request): # Check if all FQDNs for all applications are resolvable and point to current external IP - domains = [self.vmmgr.domain]+['{}.{}'.format(self.conf['apps'][app]['host'], self.vmmgr.domain) for app in self.conf['apps']] + domains = [self.vmmgr.domain]+['{}.{}'.format(self.conf['packages'][app]['host'], self.vmmgr.domain) for app in self.conf['apps']] ipv4 = tools.get_external_ip(4) ipv6 = tools.get_external_ip(6) for domain in domains: @@ -240,7 +259,7 @@ class WSGIApp(object): # Check if all applications are accessible from the internet using 3rd party ping service proto = kwargs['proto'] port = self.vmmgr.port if proto == 'https' else '80' - domains = [self.vmmgr.domain]+['{}.{}'.format(self.conf['apps'][app]['host'], self.vmmgr.domain) for app in self.conf['apps']] + domains = [self.vmmgr.domain]+['{}.{}'.format(self.conf['packages'][app]['host'], self.vmmgr.domain) for app in self.conf['apps']] for domain in domains: url = tools.compile_url(domain, port, proto) try: @@ -279,7 +298,7 @@ class WSGIApp(object): def update_common_action(self, request): # Update common settings shared between apps - admin e-mail address, Google Maps API key try: - self.vmmgr.update_common(request.form['email'], request.form['gmaps-api-key']) + self.appmgr.update_common_settings(request.form['email'], request.form['gmaps-api-key']) except BadRequest: return self.render_json({'error': request.session.lang.malformed_request()}) return self.render_json({'ok': request.session.lang.common_updated()}) @@ -298,10 +317,7 @@ class WSGIApp(object): def update_app_visibility_action(self, request): # Update application visibility on portal page try: - if request.form['value'] == 'true': - self.vmmgr.show_tiles(request.form['app']) - else: - self.vmmgr.hide_tiles(request.form['app']) + self.appmgr.update_app_visibility(request.form['app'], request.form['value'] == 'true') except (BadRequest, InvalidValueException): return self.render_json({'error': request.session.lang.malformed_request()}) return self.render_json({'ok': 'ok'}) @@ -309,55 +325,50 @@ class WSGIApp(object): def update_app_autostart_action(self, request): # Update value determining if the app should be automatically started after VM boot try: - if request.form['value'] == 'true': - self.vmmgr.enable_autostart(request.form['app']) - else: - self.vmmgr.disable_autostart(request.form['app']) + self.appmgr.update_app_autostart(request.form['app'], request.form['value'] == 'true') except (BadRequest, InvalidValueException): return self.render_json({'error': request.session.lang.malformed_request()}) return self.render_json({'ok': 'ok'}) - def enqueue_action(self, request, action): + def enqueue_app_action(self, request, action): + # Common method for enqueuing app actions try: app = request.form['app'] except BadRequest: return self.render_json({'error': request.session.lang.malformed_request()}) - app_title = self.conf['apps'][app]['title'] if app in self.conf['apps'] else self.appmgr.online_packages[app]['title'] - id,item = self.appmgr.enqueue_action(action, app) - response = self.render_json({'html': self.render_setup_apps_row(request, app, app_title, item), 'id': id}) - response.call_on_close(lambda: self.appmgr.process_action(id)) + self.queue.enqueue_action(app, action) + response = self.render_json({'ok': self.render_setup_apps_table(request)}) + response.call_on_close(self.queue.process_actions) return response def start_app_action(self, request): # Queues application start along with its dependencies - return self.enqueue_action(request, 'start_app') + return self.enqueue_app_action(request, self.appmgr.start_app) def stop_app_action(self, request): # Queues application stop along with its dependencies - return self.enqueue_action(request, 'stop_app') + return self.enqueue_app_action(request, self.appmgr.stop_app) def install_app_action(self, request): # Queues application installation - return self.enqueue_action(request, 'install_app') + return self.enqueue_app_action(request, self.appmgr.install_app) def uninstall_app_action(self, request): # Queues application uninstallation - return self.enqueue_action(request, 'uninstall_app') + return self.enqueue_app_action(request, self.appmgr.uninstall_app) - def get_progress_action(self, request): - # Gets appmgr queue status for given ids - json = {} + def get_app_status_action(self, request): + # Gets application and queue status + return self.render_json({'ok': self.render_setup_apps_table(request)}) + + def clear_app_status_action(self, request): + # Clears error status for an application try: - ids = request.form.getlist('ids[]') + app = request.form['app'] except BadRequest: return self.render_json({'error': request.session.lang.malformed_request()}) - actions = self.appmgr.get_actions(ids) - for id,item in actions.items(): - app = item.app - # In case of installation error, we need to get the name from online_packages as the app is not yet registered - app_title = self.conf['apps'][app]['title'] if app in self.conf['apps'] else self.appmgr.online_packages[app]['title'] - json[id] = {'html': self.render_setup_apps_row(request, app, app_title, item), 'last': item.finished} - return self.render_json(json) + self.queue.clear_action(app) + return self.render_json({'ok': self.render_setup_apps_table(request)}) def update_password_action(self, request): # Updates password for both HDD encryption (LUKS-on-LVM) and web interface admin account diff --git a/usr/lib/python3.6/vmmgr/wsgilang.py b/usr/lib/python3.6/vmmgr/wsgilang.py index 8785643..f9e425a 100644 --- a/usr/lib/python3.6/vmmgr/wsgilang.py +++ b/usr/lib/python3.6/vmmgr/wsgilang.py @@ -27,6 +27,20 @@ class WSGILang: 'password_changed': 'Heslo úspěšně změněno', 'reboot_initiated': 'Příkaz odeslán. Vyčkejte na restartování virtuálního stroje.', 'shutdown_initiated': 'Příkaz odeslán. Vyčkejte na vypnutí virtuálního stroje.', + 'status_queued': 've frontě', + 'status_starting': 'Spouští se', + 'status_started': 'Spuštěna', + 'status_stopping': 'Zastavuje se', + 'status_stopped': 'Zastavena', + 'status_downloading': 'Stahuje se', + 'status_installing_deps': 'Instalují se závislosti', + 'status_installing': 'Instaluje se', + 'status_uninstalling': 'Odinstalovává se', + 'status_not_installed': 'Není nainstalována', + 'action_start': 'Spustit', + 'action_stop': 'Zastavit', + 'action_install': 'Instalovat', + 'action_uninstall': 'Odinstalovat', } def __getattr__(self, key): diff --git a/usr/share/vmmgr/static/js/admin.js b/usr/share/vmmgr/static/js/admin.js index 1aaa527..52514d7 100644 --- a/usr/share/vmmgr/static/js/admin.js +++ b/usr/share/vmmgr/static/js/admin.js @@ -1,4 +1,5 @@ -var action_queue = []; +var status_interval; +var conn_fail_counter = 0; $(function() { $('#update-host').on('submit', update_host); @@ -14,11 +15,14 @@ $(function() { .on('click', '.app-start', start_app) .on('click', '.app-stop', stop_app) .on('click', '.app-install', install_app) - .on('click', '.app-uninstall', uninstall_app); + .on('click', '.app-uninstall', uninstall_app) + .on('click', '.app-clear-status', clear_app_status); $('#update-password').on('submit', update_password); $('#reboot-vm').on('click', reboot_vm); $('#shutdown-vm').on('click', shutdown_vm); - window.setInterval(check_progress, 1000); + if ($('#app-manager').length) { + status_interval = setInterval(get_app_status, 1000); + } }); function update_host() { @@ -140,56 +144,60 @@ function update_app_autostart(ev) { return _update_app('autostart', ev); } -function _do_app(action, ev) { +function _do_app(url, ev) { var el = $(ev.target); var tr = el.closest('tr'); var td = el.closest('td'); td.html('
'); - $.post('/'+action+'-app', {'app': tr.data('app')}, function(data) { + clearInterval(status_interval); + $.post(url, {'app': tr.data('app')}, function(data) { if (data.error) { - td.attr('class','error').html(data.error); - } else if (action) { - tr.html(data.html); - action_queue.push(data.id); + alert(data.error); + } else { + $('#app-manager tbody').html(data.ok); } + status_interval = setInterval(get_app_status, 1000); + }).fail(function() { + alert('Spojení se serverem bylo ztraceno'); }); return false; } function start_app(ev) { - return _do_app('start', ev); + return _do_app('/start-app', ev); } function stop_app(ev) { - return _do_app('stop', ev); + return _do_app('/stop-app', ev); } function install_app(ev) { - return _do_app('install', ev); + return _do_app('/install-app', ev); } function uninstall_app(ev) { var app = $(ev.target).closest('tr').children().first().text() if (confirm('Opravdu chcete odinstalovat aplikaci '+app+'?')) { - return _do_app('uninstall', ev); + return _do_app('/uninstall-app', ev); } return false; } -function check_progress() { - if (action_queue.length) { - $.post('/get-progress', {'ids': action_queue}, function(data) { - for (id in data) { - var app = id.split(':')[0]; - $('#app-manager tr[data-app="'+app+'"]').html(data[id].html); - if (data[id].last) { - action_queue = action_queue.filter(function(item) { - return item !== id - }); - } - } - }); - } +function clear_app_status(ev) { + return _do_app('/clear-app-status', ev); +} + +function get_app_status() { + $.get('/get-app-status', function(data) { + $('#app-manager tbody').html(data.ok); + conn_fail_counter = 0; + }).fail(function() { + conn_fail_counter++; + if (conn_fail_counter == 10) { + alert('Spojení se serverem bylo ztraceno'); + clearInterval(status_interval); + } + }); } function update_password() { diff --git a/usr/share/vmmgr/templates/404.html b/usr/share/vmmgr/templates/404.html index 364c4d6..d17c0b8 100644 --- a/usr/share/vmmgr/templates/404.html +++ b/usr/share/vmmgr/templates/404.html @@ -2,9 +2,6 @@ - - - Chyba 404 diff --git a/usr/share/vmmgr/templates/502.html b/usr/share/vmmgr/templates/502.html index 7c17d66..f20f249 100644 --- a/usr/share/vmmgr/templates/502.html +++ b/usr/share/vmmgr/templates/502.html @@ -2,14 +2,11 @@ - - - Chyba 502

Chyba spojení s aplikací

-

Aplikace ke které se pokoušíte připojit není dostupná. Nejspíše byla vypnuta správcem serveru.

+

Aplikace, ke které se pokoušíte připojit, není dostupná. Nejspíše se právě spouští nebo zastavuje. Počkejte chvíli a obnovte stránku.

diff --git a/usr/share/vmmgr/templates/503.html b/usr/share/vmmgr/templates/503.html new file mode 100644 index 0000000..4b72a21 --- /dev/null +++ b/usr/share/vmmgr/templates/503.html @@ -0,0 +1,12 @@ + + + + + + Chyba 503 + + +

Aplikace není dostupná

+

Aplikace, ke které se pokoušíte připojit, není dostupná. Nejspíše byla vypnuta správcem serveru.

+ + diff --git a/usr/share/vmmgr/templates/setup-apps-row.html b/usr/share/vmmgr/templates/setup-apps-row.html deleted file mode 100644 index 520baa7..0000000 --- a/usr/share/vmmgr/templates/setup-apps-row.html +++ /dev/null @@ -1,23 +0,0 @@ -{% set not_installed = app not in conf['apps'] %} -{% if not status %} - {% if not_installed: %} - {% set status = 'Není nainstalována' %} - {% set actions = 'Instalovat' %} - {% elif is_service_started(app): %} - {% set status = 'Spuštěna' %} - {% set actions = 'Zastavit' %} - {% else: %} - {% set status = 'Zastavena' %} - {% set actions = 'Spustit, Odinstalovat' %} - {% endif %} -{% endif %} - -{{ app_title }} - - -{% if is_error %} -{{ status|safe }} -{% else %} -{{ status|safe }} -{{ actions|safe }} -{% endif %} diff --git a/usr/share/vmmgr/templates/setup-apps-table.html b/usr/share/vmmgr/templates/setup-apps-table.html new file mode 100644 index 0000000..c2065c6 --- /dev/null +++ b/usr/share/vmmgr/templates/setup-apps-table.html @@ -0,0 +1,13 @@ +{% for app,data in app_data.items() %} + + {{ data['title'] }} + + + {% if not data['actions'] %} + {{ data['status']|safe }} + {% else %} + {{ data['status']|safe }} + {{ data['actions']|safe }} + {% endif %} + +{% endfor %} diff --git a/usr/share/vmmgr/templates/setup-apps.html b/usr/share/vmmgr/templates/setup-apps.html index 651a285..b35bbb6 100644 --- a/usr/share/vmmgr/templates/setup-apps.html +++ b/usr/share/vmmgr/templates/setup-apps.html @@ -15,15 +15,10 @@ - {% for app in all_apps %} - {% set app_title = conf['apps'][app]['title'] if app in conf['apps'] else online_packages[app]['title'] %} - - {% include 'setup-apps-row.html' %} - - {% endfor %} + {{ table|safe }} - {% if not online_packages %} + {% if not repo_reachable %}

Připojení k distribučnímu serveru se nezdařilo. Zkontrolujte přístupové údaje a připojení k síti.

{% endif %}

Přístupové údaje k distribučnímu serveru:

diff --git a/usr/share/vmmgr/templates/setup-host.html b/usr/share/vmmgr/templates/setup-host.html index 96bbfcd..055efbc 100644 --- a/usr/share/vmmgr/templates/setup-host.html +++ b/usr/share/vmmgr/templates/setup-host.html @@ -43,7 +43,7 @@