Implement global queue, improve failure resiliency

This commit is contained in:
Disassembler 2018-11-04 19:49:15 +01:00
parent ace50a79e7
commit 8f7cb14305
No known key found for this signature in database
GPG Key ID: 524BD33A0EE29499
16 changed files with 424 additions and 338 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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]

View File

@ -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:

View File

@ -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)

View File

@ -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]))

View File

@ -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 = '<div class="loader"></div>'
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 = '<span class="error">{}</span>'.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 = '<div class="loader"></div>'
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 = '<span class="error">{}</span> <a href="#" class="app-clear-status">OK</a>'.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 = '<span class="error">{}</span> <a href="#" class="app-clear-status">OK</a>'.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 = '<span class="error">{}</span> <a href="#" class="app-clear-status">OK</a>'.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 = '<span class="error">{}</span> <a href="#" class="app-clear-status">OK</a>'.format(lang.package_manager_error())
actions = None
else:
status = lang.status_uninstalling()
else:
status = '<span class="info">Spuštěna</span>'
actions = '<a href="#" class="app-stop">Zastavit</a>'
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 = '<span class="error">{}</span>'.format(lang.stop_start_error())
else:
status = '<span class="error">Zastavena</span>'
actions = '<a href="#" class="app-start">Spustit</a>, <a href="#" class="app-uninstall">Odinstalovat</a>'
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 = '<a href="#" class="app-install">{}</a>'.format(lang.action_install())
elif tools.is_service_started(app):
status = '<span class="info">{}</span>'.format(lang.status_started())
actions = '<a href="#" class="app-stop">{}</a>'.format(lang.action_stop())
else:
status = 'Instaluje se'
elif isinstance(item.data, BaseException):
status = '<span class="error">{}</span>'.format(lang.package_manager_error())
else:
status = '<span class="error">Zastavena</span>'
actions = '<a href="#" class="app-start">Spustit</a>, <a href="#" class="app-uninstall">Odinstalovat</a>'
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 = '<span class="error">{}</span>'.format(lang.package_manager_error())
else:
status = 'Není nainstalována'
actions = '<a href="#" class="app-install">Instalovat</a>'
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 = '<span class="error">{}</span>'.format(lang.status_stopped())
actions = '<a href="#" class="app-start">{}</a>, <a href="#" class="app-uninstall">{}</a>'.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

View File

@ -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):

View File

@ -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('<div class="loader"></div>');
$.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() {

View File

@ -2,9 +2,6 @@
<html lang="cs">
<head>
<meta charset="utf-8">
<meta name="author" content="TS">
<meta name="copyright" content="page is under CC BY-NC-ND 3.0 CZ">
<meta name="generator" content="Spotter.ngo">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chyba 404</title>
</head>

View File

@ -2,14 +2,11 @@
<html lang="cs">
<head>
<meta charset="utf-8">
<meta name="author" content="TS">
<meta name="copyright" content="page is under CC BY-NC-ND 3.0 CZ">
<meta name="generator" content="Spotter.ngo">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chyba 502</title>
</head>
<body>
<h1>Chyba spojení s aplikací</h1>
<p>Aplikace ke které se pokoušíte připojit není dostupná. Nejspíše byla vypnuta správcem serveru.</p>
<p>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.</p>
</body>
</html>

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chyba 503</title>
</head>
<body>
<h1>Aplikace není dostupná</h1>
<p>Aplikace, ke které se pokoušíte připojit, není dostupná. Nejspíše byla vypnuta správcem serveru.</p>
</body>
</html>

View File

@ -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 = '<a href="#" class="app-install">Instalovat</a>' %}
{% elif is_service_started(app): %}
{% set status = '<span class="info">Spuštěna</span>' %}
{% set actions = '<a href="#" class="app-stop">Zastavit</a>' %}
{% else: %}
{% set status = '<span class="error">Zastavena</span>' %}
{% set actions = '<a href="#" class="app-start">Spustit</a>, <a href="#" class="app-uninstall">Odinstalovat</a>' %}
{% endif %}
{% endif %}
<td>{{ app_title }}</td>
<td class="center"><input type="checkbox" class="app-visible"{% if not_installed %} disabled{% elif conf['apps'][app]['visible'] %} checked{% endif %}></td>
<td class="center"><input type="checkbox" class="app-autostart"{% if not_installed %} disabled{% elif is_service_autostarted(app) %} checked{% endif %}></td>
{% if is_error %}
<td colspan="2">{{ status|safe }}</td>
{% else %}
<td>{{ status|safe }}</td>
<td>{{ actions|safe }}</td>
{% endif %}

View File

@ -0,0 +1,13 @@
{% for app,data in app_data.items() %}
<tr data-app="{{ app }}">
<td>{{ data['title'] }}</td>
<td class="center"><input type="checkbox" class="app-visible"{% if not data['installed'] %} disabled{% elif data['visible'] %} checked{% endif %}></td>
<td class="center"><input type="checkbox" class="app-autostart"{% if not data['installed'] %} disabled{% elif data['autostarted'] %} checked{% endif %}></td>
{% if not data['actions'] %}
<td colspan="2">{{ data['status']|safe }}</td>
{% else %}
<td>{{ data['status']|safe }}</td>
<td>{{ data['actions']|safe }}</td>
{% endif %}
</tr>
{% endfor %}

View File

@ -15,15 +15,10 @@
</tr>
</thead>
<tbody>
{% for app in all_apps %}
{% set app_title = conf['apps'][app]['title'] if app in conf['apps'] else online_packages[app]['title'] %}
<tr data-app="{{ app }}">
{% include 'setup-apps-row.html' %}
</tr>
{% endfor %}
{{ table|safe }}
</tbody>
</table>
{% if not online_packages %}
{% if not repo_reachable %}
<p class="error">Připojení k distribučnímu serveru se nezdařilo. Zkontrolujte přístupové údaje a připojení k síti.</p>
{% endif %}
<p><strong>Přístupové údaje k distribučnímu serveru:</strong></p>

View File

@ -43,7 +43,7 @@
<ul style="column-count:3">
<li>{{ conf['host']['domain'] }}</li>
{% for app in conf['apps']|sort %}
<li>{{ conf['apps'][app]['host'] }}.{{ conf['host']['domain'] }}</li>
<li>{{ conf['packages'][app]['host'] }}.{{ conf['host']['domain'] }}</li>
{% endfor %}
</ul>
<input type="button" id="verify-dns" value="Ověřit nastavení DNS">