232 lines
8.6 KiB
Python
232 lines
8.6 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
import hashlib
|
|
import json
|
|
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 = '/srv/vm/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
|
|
self.stage = 0
|
|
self.total = total
|
|
self.downloaded = 0
|
|
|
|
def __str__(self):
|
|
# Limit the disaplyed percentage between 1 - 99 for aestethical and psychological reasons
|
|
return str(max(1, min(99, round(self.downloaded / self.total * 100))))
|
|
|
|
class AppMgr:
|
|
def __init__(self, vmmgr):
|
|
self.vmmgr = vmmgr
|
|
self.conf = vmmgr.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']), stream=stream)
|
|
|
|
def fetch_online_packages(self):
|
|
# Fetches and verifies online packages. Can raise InvalidSignature
|
|
online_packages = {}
|
|
packages = self.get_repo_resource('packages').content
|
|
packages_sig = self.get_repo_resource('packages.sig').content
|
|
with open(PUB_FILE, 'rb') as f:
|
|
pub_key = load_pem_public_key(f.read(), default_backend())
|
|
pub_key.verify(packages_sig, packages, ec.ECDSA(hashes.SHA512()))
|
|
online_packages = json.loads(packages)
|
|
# 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)
|
|
|
|
def stop_app(self, item):
|
|
if tools.is_service_started(item.app):
|
|
self.vmmgr.stop_app(item.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']]
|
|
item.data = InstallItem(sum(self.online_packages[d]['size'] for d in deps))
|
|
for dep in deps:
|
|
self.download_package(dep, item.data)
|
|
for dep in deps:
|
|
item.data.stage = 2 if dep == deps[-1] else 1
|
|
# Purge old data before unpacking to clean previous failed installation
|
|
self.purge_package(dep)
|
|
self.unpack_package(dep)
|
|
# Run uninstall script before installation to clean previous failed installation
|
|
self.run_uninstall_script(dep)
|
|
self.register_package(dep)
|
|
self.run_install_script(dep)
|
|
|
|
def uninstall_app(self, item):
|
|
# Main uninstallation function. Wrapper for uninstall script, filesystem purge and unregistration
|
|
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]
|
|
for dep in deps:
|
|
if dep not in self.get_uninstall_deps():
|
|
self.run_uninstall_script(dep)
|
|
self.purge_package(dep)
|
|
self.unregister_package(dep)
|
|
|
|
def download_package(self, name, installitem):
|
|
tmp_archive = '/tmp/{}.tar.xz'.format(name)
|
|
r = self.get_repo_resource('{}.tar.xz'.format(name), True)
|
|
with open(tmp_archive, 'wb') as f:
|
|
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
|
|
subprocess.run(['tar', 'xJf', tmp_archive], cwd='/', check=True)
|
|
os.unlink(tmp_archive)
|
|
|
|
def purge_package(self, name):
|
|
# Removes package and shared data from filesystem
|
|
lxcpath = self.conf['packages'][name]['lxcpath'] if name in self.conf['packages'] else self.online_packages[name]['lxcpath']
|
|
lxc_dir = os.path.join(LXC_ROOT, lxcpath)
|
|
if os.path.exists(lxc_dir):
|
|
shutil.rmtree(lxc_dir)
|
|
srv_dir = os.path.join('/srv/', name)
|
|
if os.path.exists(srv_dir):
|
|
shutil.rmtree(srv_dir)
|
|
lxc_log = '/var/log/lxc/{}.log'.format(name)
|
|
if os.path.exists(lxc_log):
|
|
os.unlink(lxc_log)
|
|
|
|
def register_package(self, name):
|
|
# Registers a package in local configuration
|
|
metadata = self.online_packages[name]
|
|
self.conf['packages'][name] = {
|
|
'deps': metadata['deps'],
|
|
'lxcpath': metadata['lxcpath'],
|
|
'version': metadata['version']
|
|
}
|
|
# If host definition is present, register the package as application
|
|
if 'host' in metadata:
|
|
self.conf['apps'][name] = {
|
|
'title': metadata['title'],
|
|
'host': metadata['host'],
|
|
'login': 'N/A',
|
|
'password': 'N/A',
|
|
'visible': False
|
|
}
|
|
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]
|
|
self.conf.save()
|
|
|
|
def run_install_script(self, name):
|
|
# Runs install.sh for a package, if the script is present
|
|
install_dir = os.path.join('/srv/', name, 'install')
|
|
install_script = os.path.join('/srv/', name, 'install.sh')
|
|
if os.path.exists(install_script):
|
|
subprocess.run(install_script, check=True)
|
|
os.unlink(install_script)
|
|
if os.path.exists(install_dir):
|
|
shutil.rmtree(install_dir)
|
|
|
|
def run_uninstall_script(self, name):
|
|
# Runs uninstall.sh for a package, if the script is present
|
|
uninstall_script = os.path.join('/srv/', name, 'uninstall.sh')
|
|
if os.path.exists(uninstall_script):
|
|
subprocess.run(uninstall_script, check=True)
|
|
|
|
def get_install_deps(self, name, online=True):
|
|
# Flatten dependency tree for a package while preserving the dependency order
|
|
packages = self.online_packages if online else self.conf['packages']
|
|
deps = packages[name]['deps'].copy()
|
|
for dep in deps[::-1]:
|
|
deps[:0] = [d for d in self.get_install_deps(dep, online)]
|
|
deps = list(dict.fromkeys(deps + [name]))
|
|
return deps
|
|
|
|
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)
|
|
return deps
|
|
|
|
def hash_file(file_path):
|
|
sha512 = hashlib.sha512()
|
|
with open(file_path, 'rb') as f:
|
|
while True:
|
|
data = f.read(65536)
|
|
if not data:
|
|
break
|
|
sha512.update(data)
|
|
return sha512.hexdigest()
|