Split to lxcmgr

This commit is contained in:
Disassembler 2019-09-20 10:10:25 +02:00
parent 972ca0b696
commit c3b711850e
No known key found for this signature in database
GPG Key ID: 524BD33A0EE29499
14 changed files with 555 additions and 402 deletions

118
usr/bin/lxcmgr Normal file
View File

@ -0,0 +1,118 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
import argparse
from lxcmgr import lxcmgr
from lxcmgr.pkgmgr import App, PkgMgr
parser = argparse.ArgumentParser(description='LXC container and package manager')
subparsers = parser.add_subparsers()
parser_list = subparsers.add_parser('list')
subparsers_list = parser_list.add_subparsers()
parser_list_installed = subparsers_list.add_parser('installed')
parser_list_installed.set_defaults(action='list-installed')
parser_list_online = subparsers_list.add_parser('online')
parser_list_online.set_defaults(action='list-online')
parser_list_updates = subparsers_list.add_parser('updates')
parser_list_updates.set_defaults(action='list-updates')
parser_install = subparsers.add_parser('install')
parser_install.set_defaults(action='install')
parser_install.add_argument('app', help='Application to install')
parser_update = subparsers.add_parser('update')
parser_update.set_defaults(action='update')
parser_update.add_argument('app', help='Application to update')
parser_uninstall = subparsers.add_parser('uninstall')
parser_uninstall.set_defaults(action='uninstall')
parser_uninstall.add_argument('app', help='Application to uninstall')
parser_container = subparsers.add_parser('container')
subparsers_container = parser_container.add_subparsers()
parser_container_prepare = subparsers_container.add_parser('prepare')
parser_container_prepare.set_defaults(action='container-prepare')
parser_container_prepare.add_argument('layers', help='OverlayFS LXC rootfs layers')
parser_container_prepare.add_argument('container', help='Container name')
parser_container_prepare.add_argument('lxc', nargs=argparse.REMAINDER)
parser_container_cleanup = subparsers_container.add_parser('cleanup')
parser_container_cleanup.set_defaults(action='container-cleanup')
parser_container_cleanup.add_argument('container', help='Container name')
parser_container_cleanup.add_argument('lxc', nargs=argparse.REMAINDER)
parser_container_create = subparsers_container.add_parser('create')
parser_container_create.set_defaults(action='container-create')
parser_container_create.add_argument('container', help='Container name')
parser_container_create.add_argument('lxc', nargs=argparse.REMAINDER)
parser_container_destroy = subparsers_container.add_parser('destroy')
parser_container_destroy.set_defaults(action='container-destroy')
parser_container_destroy.add_argument('container', help='Container name')
parser_container_destroy.add_argument('lxc', nargs=argparse.REMAINDER)
def print_apps(packages):
for app, meta in packages.items():
print('{} {}'.format(app, meta['version']))
for key, value in meta['meta'].items():
print(' {}: {}'.format(key, value))
def list_online():
pm = PkgMgr()
pm.fetch_online_packages()
print_apps(pm.online_packages['apps'])
def list_installed():
pm = PkgMgr()
pm.load_installed_packages()
print_apps(pm.installed_packages['apps'])
def list_updates():
pm = PkgMgr()
pm.load_installed_packages()
apps = [app for app in pm.installed_packages if pm.has_update(app)]
updates = {name: meta for (name, meta) in pm.online_packages['apps'].items() if name in apps}
print_apps(updates)
def install_app(app):
pm = PkgMgr()
app = App(app)
pm.install_app(app)
# TODO: periodicky vypisovat output
def update_app(app):
pm = PkgMgr()
pm.update_app(app)
def uninstall_app(app):
pm = PkgMgr()
pm.uninstall_app(app)
args = parser.parse_args()
if args.action == 'list-installed':
list_installed()
elif args.action == 'list-online':
list_online()
elif args.action == 'list-updates':
list_updates()
elif args.action == 'install':
install_app(args.app)
elif args.action == 'update':
update_app(args.app)
elif args.action == 'uninstall':
uninstall_app(args.app)
elif args.action == 'container-prepare':
# Used with LXC hooks on container startup
lxcmgr.prepare_container(args.container, args.layers)
elif args.action == 'container-cleanup':
# Used with LXC hooks on container stop
lxcmgr.cleanup_container(args.container)
elif args.action == 'container-create':
# Used by package installer and builder
lxcmgr.register_container(args.container)
elif args.action == 'container-destroy':
# Used by package installer and builder
lxcmgr.unregister_container(args.container)

View File

@ -2,7 +2,7 @@
# -*- coding: utf-8 -*-
import argparse
from vmmgr import lxcmgr
from vmmgr.config import Config
from vmmgr.vmmgr import VMMgr
@ -23,23 +23,6 @@ parser_unregister_app.add_argument('app', help='Application name')
parser_rebuild_issue = subparsers.add_parser('rebuild-issue')
parser_rebuild_issue.set_defaults(action='rebuild-issue')
parser_prepare_container = subparsers.add_parser('prepare-container')
parser_prepare_container.set_defaults(action='prepare-container')
parser_prepare_container.add_argument('layers', help='OverlayFS LXC rootfs layers')
parser_prepare_container.add_argument('lxc', nargs=argparse.REMAINDER)
parser_cleanup_container = subparsers.add_parser('cleanup-container')
parser_cleanup_container.set_defaults(action='cleanup-container')
parser_cleanup_container.add_argument('lxc', nargs=argparse.REMAINDER)
parser_register_container = subparsers.add_parser('register-container')
parser_register_container.set_defaults(action='register-container')
parser_register_container.add_argument('lxc', nargs=argparse.REMAINDER)
parser_unregister_container = subparsers.add_parser('unregister-container')
parser_unregister_container.set_defaults(action='unregister-container')
parser_unregister_container.add_argument('lxc', nargs=argparse.REMAINDER)
parser_register_proxy = subparsers.add_parser('register-proxy')
parser_register_proxy.set_defaults(action='register-proxy')
parser_register_proxy.add_argument('app', help='Application name')
@ -59,18 +42,6 @@ elif args.action == 'unregister-app':
elif args.action == 'rebuild-issue':
# Used by inittab on VM startup
vmmgr.rebuild_issue()
elif args.action == 'prepare-container':
# Used with LXC hooks on container startup
lxcmgr.prepare_container(args.layers)
elif args.action == 'cleanup-container':
# Used with LXC hooks on container stop
lxcmgr.cleanup_container()
elif args.action == 'register-container':
# Used by package installer and builder
lxcmgr.register_container()
elif args.action == 'unregister-container':
# Used by package installer and builder
lxcmgr.unregister_container()
elif args.action == 'register-proxy':
# Used in init scripts on application startup
vmmgr.register_proxy(args.app)

View File

@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
import hashlib
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from .paths import REPO_SIG_FILE
def verify_signature(file, signature):
# Verifies ECDSA HMAC SHA512 signature of a file
with open(REPO_SIG_FILE, 'rb') as f:
pub_key = serialization.load_pem_public_key(f.read(), default_backend())
pub_key.verify(signature, file, ec.ECDSA(hashes.SHA512()))
def verify_hash(file, expected_hash):
# Verifies SHA512 hash of a file against expected hash
sha512 = hashlib.sha512()
with open(file, 'rb') as f:
while True:
data = f.read(65536)
if not data:
break
sha512.update(data)
if sha512.hexdigest() != expected_hash:
raise InvalidSignature(file)

View File

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
import fcntl
def flock_ex(lock_file):
def decorator(target):
def wrapper(*args, **kwargs):
with open(lock_file, 'w') as lock:
fcntl.lockf(lock, fcntl.LOCK_EX)
return target(*args, **kwargs)
return wrapper
return decorator

View File

@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
import fcntl
import os
import shutil
import subprocess
from . import flock
from .paths import HOSTS_FILE, HOSTS_LOCK, LXC_ROOT
from .templates import LXC_CONTAINER
def prepare_container(container, layers):
# Remove ephemeral layer data
clean_ephemeral_layer(container)
# Prepare and mount overlayfs
rootfs = os.path.join(LXC_ROOT, container, 'rootfs')
# Unmount rootfs in case it remained mounted for whatever reason
subprocess.run(['umount', rootfs])
layers = layers.split(',')
if len(layers) == 1:
# We have only single layer, no overlay needed
subprocess.run(['mount', '--bind', layers[0], rootfs])
else:
olwork = os.path.join(LXC_ROOT, container, 'olwork')
subprocess.run(['mount', '-t', 'overlay', '-o', 'upperdir={},lowerdir={},workdir={}'.format(layers[-1], ':'.join(layers[:-1]), olwork), 'none', rootfs])
def clean_ephemeral_layer(container):
# Cleans containers ephemeral layer. Called in lxc.hook.post-stop and lxc.hook.pre-start in case of unclean shutdown
# This is done early in the container start process, so the inode of the ephemeral directory must remain unchanged
ephemeral = os.path.join(LXC_ROOT, container, 'ephemeral')
for item in os.scandir(ephemeral):
shutil.rmtree(item.path) if item.is_dir() else os.unlink(item.path)
def cleanup_container(container):
# Unmount rootfs
rootfs = os.path.join(LXC_ROOT, container, 'rootfs')
subprocess.run(['umount', rootfs])
# Remove ephemeral layer data
clean_ephemeral_layer(container)
def create_container(container, image):
# Create directories after container installation
rootfs = os.path.join(LXC_ROOT, container, 'rootfs')
olwork = os.path.join(LXC_ROOT, container, 'olwork')
ephemeral = os.path.join(LXC_ROOT, container, 'ephemeral')
os.makedirs(rootfs, 0o755, True)
os.makedirs(olwork, 0o755, True)
os.makedirs(ephemeral, 0o755, True)
os.chown(ephemeral, 100000, 100000)
# Create container configuration file
layers = ','.join([os.path.join(LXC_STORAGE_DIR, layer) for layer in image['layers']])
if 'build' not in image:
layers = '{},{}'.format(layer, ephemeral)
mounts = '\n'.join(['lxc.mount.entry = {} {} none bind,create={} 0 0'.format(m[1], m[2].lstrip('/'), m[0].lower()) for m in image['mounts']]) if 'mounts' in image else ''
env = '\n'.join(['lxc.environment = {}={}'.format(e[0], e[1]) for e in image['env']]) if 'env' in image else ''
uid = image['uid'] if 'uid' in image else '0'
gid = image['gid'] if 'gid' in image else '0'
cmd = image['cmd'] if 'cmd' in image else '/bin/sh'
cwd = image['cwd'] if 'cwd' in image else '/'
halt = image['halt'] if 'halt' in image else 'SIGINT'
# Lease the first unused IP to the container
ipv4 = update_hosts_lease(container, True)
# Create the config file
with open(os.path.join(LXC_ROOT, container, 'config'), 'w') as f:
f.write(LXC_CONTAINER.format(name=container, ipv4=ipv4, layers=layers, mounts=mounts, env=env, uid=uid, gid=gid, cmd=cmd, cwd=cwd, halt=halt))
def destroy_container(container):
# Remove container configuration and directories
shutil.rmtree(os.path.join(LXC_ROOT, container))
# Release the IP address
update_hosts_lease(container, False)
@flock.flock_ex(HOSTS_LOCK)
def update_hosts_lease(container, is_request):
# This is a poor man's DHCP server which uses /etc/hosts as lease database
# Leases the first unused IP from range 172.17.0.0/16
# Uses file lock as interprocess mutex
ip = None
# Load all existing records
with open(HOSTS_FILE, '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] for l in leases]
for i in range(2, 65278): # Reserve last /24 subnet for VPN
ip = '172.17.{}.{}'. format(i // 256, i % 256)
if ip not in used_ips:
leases.append([ip, container])
break
# Otherwise it is a release in which case we just delete the record
else:
leases = [l for l in leases if l[1] != container]
# Write the contents back to the file
with open(HOSTS_FILE, 'w') as f:
for lease in leases:
f.write('{} {}\n'.format(lease[0], lease[1]))
return ip

View File

@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
# Package manager
REPO_CACHE_DIR = '/var/lib/lxcmgr/cache'
REPO_LOCAL_FILE = '/var/lib/lxcmgr/packages'
REPO_LOCK = '/var/lock/lxcmgr-repo.lock'
REPO_SIG_FILE = '/var/lib/lxcmgr/packages.pub'
# LXC
HOSTS_FILE = '/etc/hosts'
HOSTS_LOCK = '/var/lock/lxcmgr-hosts.lock'
LXC_ROOT = '/var/lib/lxc'
LXC_STORAGE_DIR = '/var/lib/lxcmgr/storage'

View File

@ -0,0 +1,229 @@
# -*- coding: utf-8 -*-
import json
import os
import requests
import shutil
import subprocess
from enum import Enum
from pkg_resources import parse_version
from . import crypto
from . import flock
from . import lxcmgr
from .paths import LXC_STORAGE_DIR, REPO_CACHE_DIR, REPO_LOCAL_FILE, REPO_LOCK
class Stage(Enum):
QUEUED = 1
DOWNLOAD = 2
UNPACK = 3
INSTALL = 4
UNINSTALL = 5
DONE = 6
class RepoUnauthorized(Exception):
pass
class RepoFileNotFound(Exception):
pass
class RepoBadRequest(Exception):
pass
class AppInstall:
def __init__(self, name):
self.name = name
self.stage = Stage.QUEUED
self.bytes_total = 1
self.bytes_downloaded = 0
@property
def percent_downloaded(self):
# Limit the displayed percentage to 0 - 99
return min(99, round(self.bytes_downloaded / self.bytes_total * 100))
class PkgMgr:
def __init__(self, repo_url, repo_auth=None):
self.repo_url = repo_url
self.repo_auth = repo_auth
self.installed_packages = None
self.online_packages = None
def load_installed_packages(self):
with open(REPO_LOCAL_FILE, 'r') as f:
self.installed_packages = json.load(f)
def save_installed_packages(self, packages):
with open(REPO_LOCAL_FILE, 'w') as f:
json.dump(packages, f, sort_keys=True, indent=4)
def get_repo_resource(self, resource_url, stream=False):
# Download requested repository resource
r = requests.get('{}/{}'.format(self.repo_url, resource_url), auth=self.repo_auth, timeout=5, stream=stream)
if r.status_code == 401:
raise RepoUnauthorized(r.url)
elif r.status_code == 404:
raise RepoFileNotFound(r.url)
elif r.status_code != 200:
raise RepoBadRequest(r.url)
return r
def fetch_online_packages(self):
# Fetches and verifies online packages. Can raise InvalidSignature
packages = self.get_repo_resource('packages').content
packages_sig = self.get_repo_resource('packages.sig').content
crypto.verify_signature(packages, packages_sig)
self.online_packages = json.loads(packages)
@flock.flock_ex(REPO_LOCK)
def install_app(self, app):
# Main installation function. Wrapper for download, registration and install script
if not self.installed_packages:
self.load_installed_packages()
# Request for installation of already installed app immediately returns with success
if app.name in self.installed_packages['apps']:
app.stage = Stage.DONE
return
if not self.online_packages:
self.fetch_online_packages()
# Get all packages on which the app depends and which have not been installed yet
layers = []
images = [container['image'] for container in self.online_packages['apps'][app]['containers'].values()]
for image in images:
layers.extend(self.online_packages['images'][image]['layers'])
layers = [layer for layer in set(layers) if layer not in self.installed_packages['images']]
# Calculate bytes to download
app.bytes_total = sum(self.online_packages['images'][layer]['size'] for layer in layers) + self.online_packages['apps'][app.name]['size']
# Download layers and setup script files
app.stage = Stage.DOWNLOAD
for layer in layers:
self.download_layer(app, layer)
self.download_scripts(app)
# Purge old data (to clean previous failed installation) and unpack
app.stage = Stage.UNPACK
for layer in layers:
self.purge_layer(layer)
self.unpack_layer(layer)
self.purge_scripts(app.name)
self.unpack_scripts(app.name)
# Run setup scripts
app.stage = Stage.INSTALL
self.run_uninstall_script(app.name)
# Build containers and services
self.create_containers(app.name)
# Run install script and finish the installation
self.run_install_script(app.name)
self.installed_packages['apps'][app.name] = self.online_packages['apps'][app.name]
self.save_installed_packages()
app.stage = Stage.DONE
def download_layer(self, app, layer):
pkg_archive = 'images/{}.tar.xz'.format(layer)
self.download_archive(app, pkg_archive)
# Verify hash
crypto.verify_hash(tmp_archive, self.online_packages['images'][layer]['sha512'])
def download_scripts(self, app):
pkg_archive = 'apps/{}.tar.xz'.format(app.name)
self.download_archive(app, pkg_archive)
# Verify hash
crypto.verify_hash(tmp_archive, self.online_packages['apps'][app.name]['sha512'])
def download_archive(self, app, archive):
# Download the archive
tmp_archive = os.path.join(REPO_CACHE_DIR, pkg_archive)
res = self.get_repo_resource('{}/{}'.format(type, pkg_archive), True)
with open(tmp_archive, 'wb') as f:
for chunk in res.iter_content(chunk_size=65536):
if chunk:
app.bytes_downloaded += f.write(chunk)
def purge_layer(self, layer):
# Delete layer files from storage directory
shutil.rmtree(os.path.join(LXC_STORAGE_DIR, layer))
if layer in self.installed_packages['images']:
del self.installed_packages['images'][layer]
self.save_installed_packages()
def unpack_layer(self, layer):
# Unpack layer archive
tmp_archive = os.path.join(REPO_CACHE_DIR, 'images/{}.tar.xz'.format(layer))
subprocess.run(['tar', 'xJf', tmp_archive], cwd=LXC_STORAGE_DIR, check=True)
os.unlink(tmp_archive)
self.installed_packages['images'][layer] = self.online_packages['images'][layer]
self.save_installed_packages()
def purge_scripts(self, app):
# Delete application setup scripts from storage directory
shutil.rmtree(os.path.join(REPO_CACHE_DIR, 'apps', app))
del self.installed_packages['apps'][app]
self.save_installed_packages()
def unpack_scripts(self, app):
# Unpack setup scripts archive
tmp_archive = os.path.join(REPO_CACHE_DIR, 'apps/{}.tar.xz'.format(app))
subprocess.run(['tar', 'xJf', tmp_archive], cwd=os.path.join(REPO_CACHE_DIR, 'apps'), check=True)
os.unlink(tmp_archive)
def run_uninstall_script(self, app):
# Runs uninstall.sh for an app, if the script is present
self.run_script(app, 'uninstall.sh')
def run_install_script(self, app):
# Runs install.sh for a package, if the script is present
self.run_script(app, 'install.sh')
def run_script(self, app, script):
# Runs script for an app, if the script is present
script_path = os.path.join(REPO_CACHE_DIR, app, script)
if os.path.exists(script_path):
subprocess.run(script_path, check=True)
def create_containers(self, app):
# Create LXC containers from image and app metadata
for container in self.online_packages['apps'][app]['containers']:
image = self.online_packages['apps'][app]['containers'][container]['image']
image = self.online_packages['images'][image]['containers'].copy()
if 'mounts' in self.online_packages['apps'][app]['containers'][container]:
image['mounts'] = self.online_packages['apps'][app]['containers'][container]['mounts']
lxcmgr.create_container(container, image)
@flock.flock_ex(REPO_LOCK)
def uninstall_app(self, app):
# Main uninstallation function. Wrapper for uninstall script and filesystem purge
self.run_uninstall_script(app)
self.destroy_containers(app)
self.purge_scripts(app)
self.purge_unused_layers()
def destroy_containers(self, app):
# Destroy LXC containers
for container in self.installed_packages['apps'][app]['containers']:
lxcmgr.destroy_container(container)
def purge_unused_layers(self):
# Remove layers which are no longer used by any installed application
layers = set(os.list(LXC_STORAGE_DIR))
for app in self.installed_packages['apps']:
for container in self.installed_packages['apps'][app]['containers']:
image = self.installed_packages['apps'][app]['containers'][container]['image']
for layer in self.installed_packages['images'][image]['layers']:
if layer in layers:
del layers[layer]
for layer in layers:
self.purge_layer(layer)
@flock.flock_ex(REPO_LOCK)
def update_app(self, app, item):
# Main update function.
# TODO: Implement actual update
#uninstall_app(app)
#install_app(app, item)
def has_update(self, app):
if not self.installed_packages:
self.load_installed_packages()
if not self.online_packages:
self.fetch_online_packages()
return parse_version(self.installed_packages['apps'][app]['version']) < parse_version(self.online_packages['apps'][app]['version'])

View File

@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
LXC_CONTAINER = '''# Image name
lxc.uts.name = {name}
# Network
lxc.net.0.type = veth
lxc.net.0.link = lxcbr0
lxc.net.0.flags = up
lxc.net.0.ipv4.address = {ipv4}/16
lxc.net.0.ipv4.gateway = 172.17.0.1
# Volumes
lxc.rootfs.path = /var/lib/lxc/{name}/rootfs
# Mounts
lxc.mount.entry = shm dev/shm tmpfs rw,nodev,noexec,nosuid,relatime,mode=1777,create=dir 0 0
lxc.mount.entry = /etc/hosts etc/hosts none bind,create=file 0 0
lxc.mount.entry = /etc/resolv.conf etc/resolv.conf none bind,create=file 0 0
{mounts}
# Init
lxc.init.uid = {uid}
lxc.init.gid = {gid}
lxc.init.cwd = {cwd}
# Environment
lxc.environment = PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
{env}
# Halt
lxc.signal.halt = {halt}
# Log
lxc.console.size = 1MB
lxc.console.logfile = /var/log/lxc/{name}.log
# ID map
lxc.idmap = u 0 100000 65536
lxc.idmap = g 0 100000 65536
# Hooks
lxc.hook.pre-start = /usr/bin/vmmgr prepare-container {layers}
lxc.hook.post-stop = /usr/bin/vmmgr cleanup-container
# Other
lxc.arch = linux64
lxc.cap.drop = sys_admin
lxc.include = /usr/share/lxc/config/common.conf
lxc.include = /usr/share/lxc/config/userns.conf
'''

View File

@ -2,35 +2,15 @@
import bcrypt
import datetime
import hashlib
import os
from cryptography import x509
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID
from .paths import ACME_CRON, CERT_PUB_FILE, CERT_KEY_FILE, PKG_SIG_FILE
def verify_signature(file, signature):
# Verifies ECDSA HMAC SHA512 signature of a file
with open(PKG_SIG_FILE, 'rb') as f:
pub_key = serialization.load_pem_public_key(f.read(), default_backend())
pub_key.verify(signature, file, ec.ECDSA(hashes.SHA512()))
def verify_hash(file, expected_hash):
# Verifies SHA512 hash of a file against expected hash
sha512 = hashlib.sha512()
with open(file, 'rb') as f:
while True:
data = f.read(65536)
if not data:
break
sha512.update(data)
if sha512.hexdigest() != expected_hash:
raise InvalidSignature(file)
from .paths import ACME_CRON, CERT_PUB_FILE, CERT_KEY_FILE
def create_selfsigned_cert(domain):
# Create selfsigned certificate with wildcard alternative subject name

View File

@ -1,121 +0,0 @@
# -*- coding: utf-8 -*-
import fcntl
import os
import shutil
import subprocess
from .config import Config
from .paths import HOSTS_FILE, HOSTS_LOCK, LXC_ROOT
from .templates import LXC_CONTAINER
def prepare_container(layers):
# Extract the variables from values given via lxc.hook.pre-start hook
app = os.environ['LXC_NAME']
# Remove ephemeral layer data
clean_ephemeral_layer(app)
# Prepare and mount overlayfs
prepare_overlayfs(app, layers)
# Configure host and common params used in the app
configure_app(app)
def clean_ephemeral_layer(app):
# Cleans containers ephemeral layer. Called in lxc.hook.post-stop and lxc.hook.pre-start in case of unclean shutdown
# This is done early in the container start process, so the inode of the ephemeral directory must remain unchanged
ephemeral = os.path.join(LXC_ROOT, app, 'ephemeral')
for item in os.scandir(ephemeral):
shutil.rmtree(item.path) if item.is_dir() else os.unlink(item.path)
def prepare_overlayfs(app, layers):
# Prepare and mount overlayfs
rootfs = os.path.join(LXC_ROOT, app, 'rootfs')
# Unmount rootfs in case it remained mounted for whatever reason
subprocess.run(['umount', rootfs])
layers = layers.split(',')
if len(layers) == 1:
# We have only single layer, no overlay needed
subprocess.run(['mount', '--bind', layers[0], rootfs])
else:
olwork = os.path.join(LXC_ROOT, app, 'olwork')
subprocess.run(['mount', '-t', 'overlay', '-o', 'upperdir={},lowerdir={},workdir={}'.format(layers[-1], ','.join(layers[:-1]), olwork), 'none', rootfs])
def configure_app(app):
# Supply common configuration for the application. Done as part of container preparation during service startup
script = os.path.join('/srv', app, 'update-conf.sh')
if os.path.exists(script):
conf = Config()
setup_env = os.environ.copy()
setup_env['DOMAIN'] = conf['host']['domain']
setup_env['PORT'] = conf['host']['port']
setup_env['EMAIL'] = conf['common']['email']
setup_env['GMAPS_API_KEY'] = conf['common']['gmaps-api-key']
subprocess.run([script], env=setup_env, check=True)
def cleanup_container():
# Extract the variables from values given via lxc.hook.post-stop hook
app = os.environ['LXC_NAME']
# Unmount rootfs
rootfs = os.path.join(LXC_ROOT, app, 'rootfs')
subprocess.run(['umount', rootfs])
# Remove ephemeral layer data
clean_ephemeral_layer(app)
def register_container(app, image):
# Create directories after container installation
rootfs = os.path.join(LXC_ROOT, app, 'rootfs')
olwork = os.path.join(LXC_ROOT, app, 'olwork')
ephemeral = os.path.join(LXC_ROOT, app, 'ephemeral')
os.makedirs(rootfs, 0o755, True)
os.makedirs(olwork, 0o755, True)
os.makedirs(ephemeral, 0o755, True)
os.chown(ephemeral, 100000, 100000)
# Create container configuration file
layers = ','.join([os.path.join(LXC_ROOT, 'storage', layer) for layer in image['layers']])
if 'build' not in image:
layers = '{},{}'.format(layer, ephemeral)
mounts = '\n'.join(['lxc.mount.entry = {} {} none bind,create={} 0 0'.format(m[1], m[2], m[0].lower()) for m in image['mounts']]) if 'mounts' in image else ''
env = '\n'.join(['lxc.environment = {}={}'.format(e[0], e[1]) for e in image['env']]) if 'env' in image else ''
uid = image['uid'] if 'uid' in image else '0'
gid = image['gid'] if 'gid' in image else '0'
cmd = image['cmd'] if 'cmd' in image else '/bin/sh'
cwd = image['cwd'] if 'cwd' in image else '/'
halt = image['halt'] if 'halt' in image else 'SIGINT'
# Lease the first unused IP to the container
ipv4 = update_hosts_lease(app, True)
# Create the config file
with open(os.path.join(LXC_ROOT, app, 'config'), 'w') as f:
f.write(LXC_CONTAINER.format(name=app, ipv4=ipv4, layers=layers, mounts=mounts, env=env, uid=uid, gid=gid, cmd=cmd, cwd=cwd, halt=halt))
def unregister_container(app):
# Remove container configuration and directories
# TODO: Duplicated with what pkgmgr does, ale zustane to tady, protoze unregister se pouziva pri buildu
shutil.rmtree(os.path.join(LXC_ROOT, app))
# Release the IP address
update_hosts_lease(app, False)
def update_hosts_lease(app, is_request):
# This is a poor man's DHCP server which uses /etc/hosts as lease database
# Leases the first unused IP from range 172.17.0.0/16
# Uses file lock as interprocess mutex
ip = None
with open(HOSTS_LOCK, 'w') as lock:
fcntl.lockf(lock, fcntl.LOCK_EX)
# Load all existing records
with open(HOSTS_FILE, '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] for l in leases]
for i in range(2, 65278): # Reserve last /24 subnet for VPN
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(HOSTS_FILE, 'w') as f:
for lease in leases:
f.write('{} {}\n'.format(lease[0], lease[1]))
return ip

View File

@ -11,13 +11,15 @@ CERT_KEY_FILE = '/etc/ssl/services.key'
CERT_PUB_FILE = '/etc/ssl/services.pem'
# Package manager
PKG_SIG_FILE = '/etc/vmmgr/packages.pub'
PKG_TEMP_DIR = '/var/cache/vmmgr'
REPO_LOCAL_FILE = '/var/lib/lxc-pkg/packages'
REPO_SIG_FILE = '/etc/vmmgr/packages.pub'
REPO_CACHE_DIR = '/var/lib/lxc-pkg/cache'
# LXC
HOSTS_FILE = '/etc/hosts'
HOSTS_LOCK = '/var/lock/vmmgr-hosts.lock'
LXC_ROOT = '/var/lib/lxc'
LXC_STORAGE_DIR = '/var/lib/lxc-pkg/storage'
# OS
ISSUE_FILE = '/etc/issue'

View File

@ -1,178 +0,0 @@
# -*- coding: utf-8 -*-
import json
import os
import requests
import shutil
import subprocess
from enum import Enum
from pkg_resources import parse_version
from werkzeug.exceptions import BadRequest, NotFound, Unauthorized
from . import crypto
from .paths import LXC_ROOT, PKG_TEMP_DIR
class Stage(Enum):
DOWNLOAD = 1
UNPACK = 2
INSTALL_DEPS = 3
INSTALL_APP = 4
class Pkg:
def __init__(self):
self.stage = Stage.DOWNLOAD
self.bytes_total = 1
self.bytes_downloaded = 0
@property
def percent_downloaded(self):
# Limit the displayed percentage to 0 - 99
return min(99, round(self.bytes_downloaded / self.bytes_total * 100))
class PkgMgr:
def __init__(self, conf):
self.conf = conf
self.online_packages = {}
def get_repo_resource(self, resource_url, stream=False):
# Download requested repository resource
r = requests.get('{}/{}'.format(self.conf['repo']['url'], resource_url), auth=(self.conf['repo']['user'], self.conf['repo']['pwd']), timeout=5, stream=stream)
if r.status_code == 401:
raise Unauthorized(r.url)
elif r.status_code == 404:
raise NotFound(r.url)
elif r.status_code != 200:
raise BadRequest(r.url)
return r
def fetch_online_packages(self):
# Fetches and verifies online packages. Can raise InvalidSignature
packages = self.get_repo_resource('packages').content
packages_sig = self.get_repo_resource('packages.sig').content
crypto.verify_signature(packages, packages_sig)
self.online_packages = json.loads(packages)
def install_app(self, app, item):
# Main installation function. Wrapper for download, registration and install script
self.fetch_online_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']]
item.bytes_total = sum(self.online_packages[d]['size'] for d in deps)
for dep in deps:
self.download_package(dep, item)
for dep in deps:
# Purge old data before unpacking to clean previous failed installation
item.stage = Stage.UNPACK
self.purge_package(dep)
self.unpack_package(dep)
for dep in deps:
# Set stage to INSTALLING_DEPS or INSTALLING based on which package in sequence is being installed
item.stage = Stage.INSTALL_APP if dep == deps[-1] else Stage.INSTALL_DEPS
# Run uninstall script before installation to clean previous failed installation
self.run_uninstall_script(dep)
self.run_install_script(dep)
self.register_package(dep)
def uninstall_app(self, app):
# Main uninstallation function. Wrapper for uninstall script, filesystem purge and unregistration
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)
self.purge_package(dep)
self.unregister_package(dep)
def update_app(self, app, item):
# Main update function.
# TODO: Implement actual update
uninstall_app(app)
install_app(app, item)
def download_package(self, name, item):
# Download tar.xz package and verify its hash. Can raise InvalidSignature
pkg_archive = '{}_{}-{}.tar.xz'.format(name, self.online_packages[name]['version'], self.online_packages[name]['release'])
tmp_archive = os.path.join(PKG_TEMP_DIR, pkg_archive)
os.makedirs(PKG_TEMP_DIR, 0o700, True)
# If the archive already exists in temp (presumably because the previous installation was interrupted), it was already verified and can be reused
if os.path.exists(tmp_archive):
item.bytes_downloaded += os.path.getsize(tmp_archive)
return
# Download the archive
partial_archive = '{}.partial'.format(tmp_archive)
res = self.get_repo_resource(pkg_archive, True)
with open(partial_archive, 'wb') as f:
for chunk in res.iter_content(chunk_size=65536):
if chunk:
item.bytes_downloaded += f.write(chunk)
# Verify hash
crypto.verify_hash(partial_archive, self.online_packages[name]['sha512'])
# Remove ".partial" extension
os.rename(partial_archive, tmp_archive)
def unpack_package(self, name):
# Unpack archive
pkg_archive = '{}_{}-{}.tar.xz'.format(name, self.online_packages[name]['version'], self.online_packages[name]['release'])
tmp_archive = os.path.join(PKG_TEMP_DIR, pkg_archive)
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 installed packages
metadata = self.online_packages[name].copy()
del metadata['sha512']
del metadata['size']
self.conf['packages'][name] = metadata
self.conf.save()
def unregister_package(self, name):
# Removes a package from installed packages
del self.conf['packages'][name]
self.conf.save()
def run_install_script(self, name):
# Runs install.sh for a package, if the script is present
install_script = os.path.join('/srv/', name, 'install.sh')
if os.path.exists(install_script):
subprocess.run(install_script, check=True)
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]['depends'].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 name in self.conf['packages'].copy():
for d in self.conf['packages'][name]['depends']:
deps.setdefault(d, []).append(name)
return deps
def has_update(self, app):
if not self.online_packages:
return False
return parse_version(self.conf['packages'][app]['version']) < parse_version(self.online_packages[app]['version'])

View File

@ -112,53 +112,3 @@ ISSUE = '''
- \x1b[1m{url}\x1b[0m
- \x1b[1m{ip}\x1b[0m\x1b[?1c
'''
LXC_CONTAINER = '''# Image name
lxc.uts.name = {name}
# Network
lxc.net.0.type = veth
lxc.net.0.link = lxcbr0
lxc.net.0.flags = up
lxc.net.0.ipv4.address = {ipv4}/16
lxc.net.0.ipv4.gateway = 172.17.0.1
# Volumes
lxc.rootfs.path = /var/lib/lxc/{name}/rootfs
# Mounts
lxc.mount.entry = shm dev/shm tmpfs rw,nodev,noexec,nosuid,relatime,mode=1777,create=dir 0 0
lxc.mount.entry = /etc/hosts etc/hosts none bind,create=file 0 0
lxc.mount.entry = /etc/resolv.conf etc/resolv.conf none bind,create=file 0 0
{mounts}
# Init
lxc.init.uid = {uid}
lxc.init.gid = {gid}
lxc.init.cwd = {cwd}
# Environment
lxc.environment = PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
{env}
# Halt
lxc.signal.halt = {halt}
# Log
lxc.console.size = 1MB
lxc.console.logfile = /var/log/lxc/{name}.log
# ID map
lxc.idmap = u 0 100000 65536
lxc.idmap = g 0 100000 65536
# Hooks
lxc.hook.pre-start = /usr/bin/vmmgr prepare-container {layers}
lxc.hook.post-stop = /usr/bin/vmmgr cleanup-container
# Other
lxc.arch = linux64
lxc.cap.drop = sys_admin
lxc.include = /usr/share/lxc/config/common.conf
lxc.include = /usr/share/lxc/config/userns.conf
'''