Compare commits
13 Commits
Author | SHA1 | Date | |
---|---|---|---|
9351887546 | |||
6ced9772ba | |||
c2cd5b12a0 | |||
d2e17c8d49 | |||
855c5526f6 | |||
c3a73f2b28 | |||
4420d64c45 | |||
05f4d7955b | |||
612497abb1 | |||
48a08d8fa3 | |||
5bc8a878dc | |||
0b585dee0d | |||
7004b0767e |
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
.coverage
|
||||
.vscode/
|
||||
*.egg-info/
|
||||
coverage.xml
|
21
APKBUILD
21
APKBUILD
@ -1,21 +0,0 @@
|
||||
# Contributor: Disassembler <disassembler@dasm.cz>
|
||||
# Maintainer: Disassembler <disassembler@dasm.cz>
|
||||
pkgname=spoc
|
||||
pkgver=0.9.3
|
||||
pkgrel=0
|
||||
pkgdesc="SPOC application, container, and image manager"
|
||||
url="https://spotter.vm/"
|
||||
arch="noarch"
|
||||
license="GPL"
|
||||
depends="lxc python3 py3-cffi py3-cryptography py3-requests"
|
||||
options="!check !strip"
|
||||
|
||||
build() {
|
||||
return 0
|
||||
}
|
||||
|
||||
package() {
|
||||
mkdir -p ${pkgdir}
|
||||
cp -rp etc ${pkgdir}
|
||||
cp -rp usr ${pkgdir}
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
#!/sbin/openrc-run
|
||||
|
||||
description="SPOC"
|
||||
|
||||
depend() {
|
||||
need localmount sysfs cgroups
|
||||
after firewall net
|
||||
}
|
||||
|
||||
start() {
|
||||
/usr/bin/spoc-app start-autostarted
|
||||
}
|
||||
|
||||
stop() {
|
||||
/usr/bin/spoc-app stop-all
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
[general]
|
||||
data-dir = /var/lib/spoc/
|
||||
log-dir = /var/log/spoc/
|
||||
network-interface = spocbr0
|
||||
resolv-conf = /etc/resolv.conf
|
||||
|
||||
[publish]
|
||||
publish-dir = /srv/build/spoc/
|
||||
signing-key = /etc/spoc/publish.key
|
||||
|
||||
[repo]
|
||||
url = https://repo.spotter.cz/spoc/
|
||||
public-key = MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEWJXH4Qm0kt2L86sntQH+C1zOJNQ0qMRt0vx4krTxRs9HQTQYAy//JC92ea2aKleA8OL0JF90b1NYXcQCWdAS+vE/ng9IEAii8C2+5nfuFeZ5YUjbQhfFblwHSM0c7hEG
|
60
setup.cfg
Normal file
60
setup.cfg
Normal file
@ -0,0 +1,60 @@
|
||||
[metadata]
|
||||
name = spoc
|
||||
version = 2.0.0
|
||||
license = GPLv3+
|
||||
author = Disassembler
|
||||
author_email = disassembler@dasm.cz
|
||||
description = SPOC application and container manager. A simple orchestrator for podman.
|
||||
classifiers =
|
||||
Development Status :: 5 - Production/Stable
|
||||
Environment :: Console
|
||||
Intended Audience :: System Administrators
|
||||
License :: OSI Approved :: GNU General Public License v3 or later
|
||||
Operating System :: POSIX
|
||||
Programming Language :: Python :: 3.6
|
||||
Programming Language :: Python :: 3.7
|
||||
Programming Language :: Python :: 3.8
|
||||
Programming Language :: Python :: 3.9
|
||||
Topic :: System :: Installation/Setup
|
||||
Topic :: System :: Systems Administration
|
||||
|
||||
[options]
|
||||
packages = find:
|
||||
package_dir = =src
|
||||
py_modules = spoc_cli
|
||||
python_requires = >= 3.6
|
||||
install_requires = requests
|
||||
|
||||
[options.packages.find]
|
||||
where = src
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
spoc = spoc_cli:main
|
||||
|
||||
[tool:pytest]
|
||||
testpaths = tests
|
||||
|
||||
[coverage:run]
|
||||
branch = True
|
||||
omit =
|
||||
*/dist-packages/*
|
||||
*/site-packages/*
|
||||
|
||||
[pylint.BASIC]
|
||||
good-names = e,ex,f,_
|
||||
|
||||
[pylint.'MESSAGES CONTROL']
|
||||
disable = missing-docstring
|
||||
|
||||
[tox:tox]
|
||||
|
||||
[testenv:{pylint,pytest}]
|
||||
skipsdist = True
|
||||
usedevelop = True
|
||||
deps =
|
||||
pylint
|
||||
pytest-cov
|
||||
commands =
|
||||
pytest: pytest -vv --cov src --cov tests --cov-report term --cov-report xml --cov-fail-under 100 {posargs}
|
||||
pylint: pylint src tests {posargs}
|
6
setup.py
Normal file
6
setup.py
Normal file
@ -0,0 +1,6 @@
|
||||
# This file is intended to be used only by PEP 517 incompatible build frontends
|
||||
# Metadata are in setup.cfg
|
||||
|
||||
from setuptools import setup
|
||||
|
||||
setup()
|
104
src/spoc/__init__.py
Normal file
104
src/spoc/__init__.py
Normal file
@ -0,0 +1,104 @@
|
||||
from pkg_resources import parse_version
|
||||
|
||||
from . import app
|
||||
from . import autostart
|
||||
from . import config
|
||||
from . import podman
|
||||
from . import repo
|
||||
from .flock import locked
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
def __init__(self, app_name):
|
||||
super().__init__(app_name)
|
||||
self.app_name = app_name
|
||||
|
||||
class AppAlreadyInstalledError(AppError):
|
||||
pass
|
||||
|
||||
class AppNotInstalledError(AppError):
|
||||
pass
|
||||
|
||||
class AppNotInRepoError(AppError):
|
||||
pass
|
||||
|
||||
class AppNotUpdateableError(AppError):
|
||||
pass
|
||||
|
||||
|
||||
def list_installed():
|
||||
return dict(sorted(podman.get_apps().items()))
|
||||
|
||||
def list_online():
|
||||
return {app:definition['version'] for app,definition in sorted(repo.get_apps().items())}
|
||||
|
||||
def list_updates():
|
||||
online_apps = {app:definition['version'] for app,definition in repo.get_apps().items()}
|
||||
apps = {app:f'{version} -> {online_apps[app]}' for app,version in podman.get_apps().items()
|
||||
if app in online_apps
|
||||
and parse_version(online_apps[app]) > parse_version(version)}
|
||||
return dict(sorted(apps.items()))
|
||||
|
||||
@locked()
|
||||
def install(app_name, from_file=None):
|
||||
if app_name in podman.get_apps():
|
||||
raise AppAlreadyInstalledError(app_name)
|
||||
if not from_file and app_name not in repo.get_apps():
|
||||
raise AppNotInRepoError(app_name)
|
||||
app.install(app_name, from_file=from_file)
|
||||
|
||||
@locked()
|
||||
def update(app_name, from_file=None):
|
||||
if app_name not in podman.get_apps():
|
||||
raise AppNotInstalledError(app_name)
|
||||
if not from_file and app_name not in list_updates():
|
||||
raise AppNotUpdateableError(app_name)
|
||||
app.update(app_name, from_file=from_file)
|
||||
|
||||
@locked()
|
||||
def uninstall(app_name):
|
||||
if app_name not in podman.get_apps():
|
||||
raise AppNotInstalledError(app_name)
|
||||
app.uninstall(app_name)
|
||||
|
||||
@locked()
|
||||
def start(app_name):
|
||||
if app_name not in podman.get_apps():
|
||||
raise AppNotInstalledError(app_name)
|
||||
podman.start_pod(app_name)
|
||||
|
||||
@locked()
|
||||
def stop(app_name):
|
||||
if app_name not in podman.get_apps():
|
||||
raise AppNotInstalledError(app_name)
|
||||
podman.stop_pod(app_name)
|
||||
|
||||
def status(app_name=None):
|
||||
if app_name is not None and app_name not in podman.get_apps():
|
||||
raise AppNotInstalledError(app_name)
|
||||
return podman.get_pod_status(app_name)
|
||||
|
||||
@locked()
|
||||
def set_autostart(app_name, enabled):
|
||||
if app_name not in podman.get_apps():
|
||||
raise AppNotInstalledError(app_name)
|
||||
autostart.set_app(app_name, enabled)
|
||||
|
||||
@locked()
|
||||
def start_autostarted():
|
||||
for app_name in autostart.get_apps():
|
||||
podman.start_pod(app_name)
|
||||
|
||||
@locked()
|
||||
def stop_all():
|
||||
for app_name in podman.get_apps():
|
||||
podman.stop_pod(app_name)
|
||||
|
||||
@locked()
|
||||
def login(host, username, password):
|
||||
config.write_auth(host, username, password)
|
||||
repo.load(force=True)
|
||||
|
||||
@locked()
|
||||
def prune():
|
||||
podman.prune()
|
141
src/spoc/app.py
Normal file
141
src/spoc/app.py
Normal file
@ -0,0 +1,141 @@
|
||||
import os
|
||||
import json
|
||||
|
||||
from . import autostart
|
||||
from . import config
|
||||
from . import depsolver
|
||||
from . import podman
|
||||
from . import repo
|
||||
|
||||
class App:
|
||||
def __init__(self, app_name):
|
||||
self.app_name = app_name
|
||||
self.env_file = os.path.join(config.DATA_DIR, f'{app_name}.env')
|
||||
|
||||
def get_definition(self, from_file=None):
|
||||
if from_file:
|
||||
with open(from_file, encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
return repo.get_apps()[self.app_name]
|
||||
|
||||
def install(self, is_update=False, from_file=None):
|
||||
definition = self.get_definition(from_file)
|
||||
version = definition['version']
|
||||
containers = definition['containers']
|
||||
|
||||
# Create volumes
|
||||
volumes = set()
|
||||
for container in containers.values():
|
||||
volumes |= set(container.get('volumes', {}))
|
||||
existing_volumes = self.get_existing_volumes()
|
||||
if is_update:
|
||||
# Remove volumes no longer referenced by the containers
|
||||
volumes_to_remove = existing_volumes - volumes
|
||||
volumes -= existing_volumes
|
||||
else:
|
||||
# If this is a clean install, remove all volumes with the app label
|
||||
volumes_to_remove = existing_volumes
|
||||
self.remove_volumes(volumes_to_remove)
|
||||
self.create_volumes(volumes)
|
||||
|
||||
# Create env file
|
||||
envs = definition.get('environment', {})
|
||||
if is_update:
|
||||
# Keep old values on update
|
||||
for key,value in self.read_env_vars().items():
|
||||
if key in envs:
|
||||
envs[key] = value
|
||||
self.write_env_vars(envs)
|
||||
|
||||
# Create pod and containers
|
||||
self.create_pod(version)
|
||||
self.create_containers(containers)
|
||||
|
||||
def update(self, from_file=None):
|
||||
self.install(is_update=True, from_file=from_file)
|
||||
|
||||
def uninstall(self):
|
||||
autostart.set_app(self.app_name, False)
|
||||
self.remove_pod()
|
||||
self.remove_env_vars()
|
||||
self.remove_volumes(self.get_existing_volumes())
|
||||
|
||||
def create_pod(self, version):
|
||||
podman.remove_pod(self.app_name)
|
||||
podman.create_pod(self.app_name, version)
|
||||
|
||||
def remove_pod(self):
|
||||
podman.remove_pod(self.app_name)
|
||||
|
||||
def read_env_vars(self):
|
||||
env_vars = {}
|
||||
try:
|
||||
with open(self.env_file, encoding='utf-8') as f:
|
||||
lines = f.read().splitlines()
|
||||
for line in lines:
|
||||
key,value = line.split('=', 1)
|
||||
env_vars[key] = value
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return env_vars
|
||||
|
||||
def write_env_vars(self, env_vars):
|
||||
os.makedirs(config.DATA_DIR, exist_ok=True)
|
||||
with open(self.env_file, 'w', encoding='utf-8') as f:
|
||||
for key,value in env_vars.items():
|
||||
f.write(f'{key}={value}\n')
|
||||
|
||||
def remove_env_vars(self):
|
||||
try:
|
||||
os.unlink(self.env_file)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def get_existing_volumes(self):
|
||||
existing_volumes = podman.get_volumes_for_app(self.app_name)
|
||||
strip_len = len(self.app_name)+1
|
||||
return set(volume[strip_len:] for volume in existing_volumes)
|
||||
|
||||
def create_volumes(self, volumes):
|
||||
for volume in volumes:
|
||||
self.create_volume(volume)
|
||||
|
||||
def remove_volumes(self, volumes):
|
||||
for volume in volumes:
|
||||
self.remove_volume(volume)
|
||||
|
||||
def create_volume(self, volume):
|
||||
volume = f'{self.app_name}-{volume}'
|
||||
podman.create_volume(self.app_name, volume)
|
||||
|
||||
def remove_volume(self, volume):
|
||||
volume = f'{self.app_name}-{volume}'
|
||||
podman.remove_volume(volume)
|
||||
|
||||
def create_containers(self, containers):
|
||||
deps = depsolver.DepSolver()
|
||||
for name,definition in containers.items():
|
||||
deps.add(name, definition.get('requires', []))
|
||||
container_order = deps.solve()
|
||||
|
||||
hosts = set(containers)
|
||||
for name in container_order:
|
||||
self.create_container(name, containers[name], hosts)
|
||||
|
||||
def create_container(self, name, definition, hosts):
|
||||
name = f'{self.app_name}-{name}'
|
||||
image = definition['image']
|
||||
volumes = {f'{self.app_name}-{volume}':mount
|
||||
for volume,mount in definition.get('volumes', {}).items()}
|
||||
requires = set(f'{self.app_name}-{require}' for require in definition.get('requires', []))
|
||||
podman.create_container(self.app_name, name, image, env_file=self.env_file,
|
||||
volumes=volumes, requires=requires, hosts=hosts)
|
||||
|
||||
def install(app_name, from_file=None):
|
||||
App(app_name).install(from_file=from_file)
|
||||
|
||||
def update(app_name, from_file=None):
|
||||
App(app_name).update(from_file=from_file)
|
||||
|
||||
def uninstall(app_name):
|
||||
App(app_name).uninstall()
|
25
src/spoc/autostart.py
Normal file
25
src/spoc/autostart.py
Normal file
@ -0,0 +1,25 @@
|
||||
import os
|
||||
|
||||
from . import config
|
||||
|
||||
def get_apps():
|
||||
try:
|
||||
with open(config.AUTOSTART_FILE, encoding='utf-8') as f:
|
||||
lines = f.read().splitlines()
|
||||
return set(lines)
|
||||
except FileNotFoundError:
|
||||
return set()
|
||||
|
||||
def set_app(app_name, enabled):
|
||||
apps = get_apps()
|
||||
if enabled:
|
||||
apps.add(app_name)
|
||||
else:
|
||||
try:
|
||||
apps.remove(app_name)
|
||||
except KeyError:
|
||||
return
|
||||
os.makedirs(config.DATA_DIR, exist_ok=True)
|
||||
with open(config.AUTOSTART_FILE, 'w', encoding='utf-8') as f:
|
||||
for app in apps:
|
||||
f.write(f'{app}\n')
|
38
src/spoc/config.py
Normal file
38
src/spoc/config.py
Normal file
@ -0,0 +1,38 @@
|
||||
import json
|
||||
from base64 import b64decode, b64encode
|
||||
|
||||
DATA_DIR = '/var/lib/spoc'
|
||||
AUTOSTART_FILE = '/var/lib/spoc/autostart'
|
||||
LOCK_FILE = '/run/lock/spoc.lock'
|
||||
|
||||
REGISTRY_HOST = None
|
||||
REGISTRY_AUTH = None
|
||||
REGISTRY_AUTH_FILE = '/var/lib/spoc/auth.json'
|
||||
REPO_FILE_URL = None
|
||||
|
||||
def read_auth():
|
||||
global REGISTRY_HOST, REGISTRY_AUTH, REPO_FILE_URL # pylint: disable=global-statement
|
||||
|
||||
try:
|
||||
with open(REGISTRY_AUTH_FILE, encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
REGISTRY_HOST = next(iter(data['auths'].keys()))
|
||||
auth = b64decode(data['auths'][REGISTRY_HOST]['auth'].encode()).decode()
|
||||
REGISTRY_AUTH = tuple(auth.split(':', 1))
|
||||
except FileNotFoundError:
|
||||
REGISTRY_HOST = 'localhost'
|
||||
REGISTRY_AUTH = None
|
||||
REPO_FILE_URL = f'https://{REGISTRY_HOST}/repository.json'
|
||||
|
||||
def write_auth(host, username, password):
|
||||
global REGISTRY_HOST, REGISTRY_AUTH, REPO_FILE_URL # pylint: disable=global-statement
|
||||
|
||||
b64auth = b64encode(f'{username}:{password}'.encode()).decode()
|
||||
data = json.dumps({'auths': {host: {'auth': b64auth}}})
|
||||
with open(REGISTRY_AUTH_FILE, 'w', encoding='utf-8') as f:
|
||||
f.write(data)
|
||||
REGISTRY_HOST = host
|
||||
REGISTRY_AUTH = (username, password)
|
||||
REPO_FILE_URL = f'https://{REGISTRY_HOST}/repository.json'
|
||||
|
||||
read_auth()
|
57
src/spoc/depsolver.py
Normal file
57
src/spoc/depsolver.py
Normal file
@ -0,0 +1,57 @@
|
||||
class CircularDependencyError(Exception):
|
||||
# Dependecy solver has found a circular dependency between items
|
||||
def __init__(self, deps):
|
||||
super().__init__(deps)
|
||||
self.deps = deps
|
||||
|
||||
def __str__(self):
|
||||
result = ['Dependency resolution failed due to circular dependency.',
|
||||
'Unresolved dependencies:']
|
||||
result.extend(f' {item} => {item_deps}' for item, item_deps in self.deps.items())
|
||||
return '\n'.join(result)
|
||||
|
||||
class MissingDependencyError(Exception):
|
||||
# Dependecy solver has found an item that depends on a nonexistent item
|
||||
def __init__(self, deps, missing):
|
||||
super().__init__(deps, missing)
|
||||
self.deps = deps
|
||||
self.missing = missing
|
||||
|
||||
def __str__(self):
|
||||
result = ['Dependency resolution failed due to missing dependency.',
|
||||
'Missing dependencies:']
|
||||
result.append(f' {self.missing}')
|
||||
result.append('Unresolved dependencies:')
|
||||
result.extend(f' {item} => {item_deps}' for item, item_deps in self.deps.items())
|
||||
return '\n'.join(result)
|
||||
|
||||
class DepSolver:
|
||||
def __init__(self):
|
||||
self.unresolved = {}
|
||||
|
||||
def add(self, item, dependencies):
|
||||
self.unresolved[item] = set(dependencies)
|
||||
|
||||
def solve(self):
|
||||
# Returns a list of instances ordered by dependency
|
||||
resolved = []
|
||||
while self.unresolved:
|
||||
# Get a batch of items not depending on anything
|
||||
# or originally depending on already resolved items
|
||||
batch = {item for item,deps in self.unresolved.items() if not deps}
|
||||
if not batch:
|
||||
# If there are no such items, check if a dependecy is missing
|
||||
wanted_deps = set(dep for deps in self.unresolved.values() for dep in deps)
|
||||
missing_deps = wanted_deps - set(self.unresolved)
|
||||
if missing_deps:
|
||||
raise MissingDependencyError(self.unresolved, missing_deps)
|
||||
# If all dependencies exist, we have found a circular dependency
|
||||
raise CircularDependencyError(self.unresolved)
|
||||
# Add resolved items to the result and remove from the unresolved ones
|
||||
for item in batch:
|
||||
resolved.append(item)
|
||||
del self.unresolved[item]
|
||||
# Remove resolved items from the dependencies of yet unresolved items
|
||||
for item in self.unresolved:
|
||||
self.unresolved[item] -= batch
|
||||
return resolved
|
49
src/spoc/flock.py
Normal file
49
src/spoc/flock.py
Normal file
@ -0,0 +1,49 @@
|
||||
import errno
|
||||
import fcntl
|
||||
import os
|
||||
import time
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
|
||||
from . import config
|
||||
|
||||
def print_lock(pid):
|
||||
with open(os.path.join('/proc', pid, 'cmdline'), 'rb') as f:
|
||||
cmdline = f.read().decode().replace('\0', ' ').strip()
|
||||
print(f'Waiting for lock currently held by process {pid} - {cmdline}', file=sys.stderr)
|
||||
|
||||
@contextmanager
|
||||
def locked():
|
||||
with open(config.LOCK_FILE, 'a', encoding='utf-8'):
|
||||
# Open the lock file in append mode first to ensure its existence
|
||||
# but not modify any data if it already exists
|
||||
pass
|
||||
# Open the lock file in read + write mode without truncation
|
||||
with open(config.LOCK_FILE, 'r+', encoding='utf-8') as f:
|
||||
lock_printed = False
|
||||
while True:
|
||||
try:
|
||||
# Try to obtain exclusive lock in non-blocking mode
|
||||
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except OSError as e:
|
||||
# If lock is held by another process
|
||||
if e.errno == errno.EAGAIN:
|
||||
if not lock_printed:
|
||||
# Print a message using contents of the lock file
|
||||
# (PID of the process holding the lock)
|
||||
print_lock(f.read())
|
||||
# Set flag so the message is not printed in every loop
|
||||
lock_printed = True
|
||||
# Set the position for future truncation
|
||||
f.seek(0)
|
||||
# Wait for the lock to be freed
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
raise
|
||||
# If the lock was obtained, truncate the file
|
||||
# and write PID of the process holding the lock
|
||||
f.truncate()
|
||||
f.write(str(os.getpid()))
|
||||
f.flush()
|
||||
yield f
|
93
src/spoc/podman.py
Normal file
93
src/spoc/podman.py
Normal file
@ -0,0 +1,93 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from . import config
|
||||
|
||||
ENV = os.environ.copy()
|
||||
ENV['REGISTRY_AUTH_FILE'] = config.REGISTRY_AUTH_FILE
|
||||
|
||||
def run(cmd, **kwargs):
|
||||
return subprocess.run(cmd, check=True, env=ENV, **kwargs)
|
||||
|
||||
def out(cmd, **kwargs):
|
||||
return run(cmd, stdout=subprocess.PIPE, text=True, **kwargs).stdout.rstrip()
|
||||
|
||||
def get_subuidgid():
|
||||
def get_first_sub(kind):
|
||||
lines = out(['su', '-', 'spoc', '-c', f'podman unshare cat /proc/self/{kind}_map'])
|
||||
for line in lines.splitlines():
|
||||
columns = line.split()
|
||||
if columns[0] == '1':
|
||||
return int(columns[1])
|
||||
return 0
|
||||
return (get_first_sub('uid'), get_first_sub('gid'))
|
||||
|
||||
def get_apps():
|
||||
apps = {}
|
||||
data = json.loads(out(['podman', 'pod', 'ps', '--format', 'json']))
|
||||
for pod in data:
|
||||
app_name = pod['Labels'].get('spoc.app')
|
||||
app_version = pod['Labels'].get('spoc.version')
|
||||
if app_name:
|
||||
apps[app_name] = app_version
|
||||
return apps
|
||||
|
||||
def get_volumes_for_app(app_name):
|
||||
volume_ls = out(['podman', 'volume', 'ls', '--filter', f'label=spoc.app={app_name}',
|
||||
'--format', 'json'])
|
||||
return set(volume['Name'] for volume in json.loads(volume_ls))
|
||||
|
||||
def start_pod(app_name):
|
||||
run(['podman', 'pod', 'start', app_name])
|
||||
|
||||
def stop_pod(app_name):
|
||||
run(['podman', 'pod', 'stop', '--ignore', app_name])
|
||||
|
||||
def get_pod_status(app_name=None):
|
||||
app_filter = 'label=spoc.app'
|
||||
if app_name:
|
||||
app_filter = f'{app_filter}={app_name}'
|
||||
return out(['podman', 'pod', 'ps', '--filter', app_filter])
|
||||
|
||||
def create_volume(app_name, vol_name):
|
||||
subuid, subgid = get_subuidgid()
|
||||
run(['podman', 'volume', 'create',
|
||||
'--opt', f'o=uid={subuid},gid={subgid}',
|
||||
'--label', f'spoc.app={app_name}', vol_name])
|
||||
|
||||
def remove_volume(vol_name):
|
||||
run(['podman', 'volume', 'rm', vol_name])
|
||||
|
||||
def create_pod(app_name, app_version):
|
||||
run(['podman', 'pod', 'create', '--name', app_name,
|
||||
'--subuidname', 'spoc', '--subgidname', 'spoc',
|
||||
'--label', f'spoc.app={app_name}', '--label', f'spoc.version={app_version}'])
|
||||
|
||||
def remove_pod(app_name):
|
||||
stop_pod(app_name)
|
||||
run(['podman', 'pod', 'rm', '--ignore', app_name])
|
||||
|
||||
def create_container(app_name, cnt_name, image, **kwargs):
|
||||
cmd = ['podman', 'container', 'create', '--name', cnt_name, '--pod', app_name,
|
||||
'--subuidname', 'spoc', '--subgidname', 'spoc',
|
||||
'--restart', 'unless-stopped']
|
||||
env_file = kwargs.get('env_file')
|
||||
if env_file:
|
||||
cmd.extend(['--env-file', env_file])
|
||||
requires = kwargs.get('requires')
|
||||
if requires:
|
||||
cmd.extend(['--requires', ','.join(sorted(requires))])
|
||||
volumes = kwargs.get('volumes')
|
||||
if volumes:
|
||||
for volume,mount in sorted(volumes.items(), key=lambda x: x[1]):
|
||||
cmd.extend(['--volume', f'{volume}:{mount}'])
|
||||
hosts = kwargs.get('hosts')
|
||||
if hosts:
|
||||
for host in sorted(hosts):
|
||||
cmd.extend(['--add-host', f'{host}:127.0.0.1'])
|
||||
cmd.append(image)
|
||||
run(cmd)
|
||||
|
||||
def prune():
|
||||
run(['podman', 'image', 'prune', '--all', '--force', '--volumes'])
|
17
src/spoc/repo.py
Normal file
17
src/spoc/repo.py
Normal file
@ -0,0 +1,17 @@
|
||||
import requests
|
||||
|
||||
from . import config
|
||||
|
||||
_DATA = {}
|
||||
|
||||
def load(force=False):
|
||||
global _DATA # pylint: disable=global-statement
|
||||
if not _DATA or force:
|
||||
_DATA = {}
|
||||
response = requests.get(config.REPO_FILE_URL, auth=config.REGISTRY_AUTH, timeout=5)
|
||||
response.raise_for_status()
|
||||
_DATA = response.json()
|
||||
|
||||
def get_apps():
|
||||
load()
|
||||
return _DATA
|
176
src/spoc_cli.py
Normal file
176
src/spoc_cli.py
Normal file
@ -0,0 +1,176 @@
|
||||
import argparse
|
||||
import getpass
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
import spoc
|
||||
|
||||
APP_ERROR_STRINGS = {
|
||||
'AppAlreadyInstalledError': 'Application {} is already installed',
|
||||
'AppNotInstalledError': 'Application {} is not installed',
|
||||
'AppNotInRepoError': 'Application {} does not exist in the repository',
|
||||
'AppNotUpdateableError': 'Application {} does not have a newer version to update',
|
||||
}
|
||||
|
||||
def handle_app_error(exception):
|
||||
ex_type = type(exception).__name__
|
||||
print(APP_ERROR_STRINGS[ex_type].format(exception.app_name), file=sys.stderr)
|
||||
|
||||
def handle_repo_error(exception):
|
||||
if isinstance(exception, requests.HTTPError):
|
||||
status_code = exception.response.status_code
|
||||
if status_code == 401:
|
||||
reason = 'Invalid username/password'
|
||||
else:
|
||||
reason = f'{status_code} {exception.response.reason}'
|
||||
else:
|
||||
ex_type = type(exception)
|
||||
reason = f'{ex_type.__module__}.{ex_type.__name__}'
|
||||
print(f'Repository "{spoc.config.REGISTRY_HOST}" cannot be reached due to: {reason}',
|
||||
file=sys.stderr)
|
||||
|
||||
def listing(list_type):
|
||||
if list_type == 'installed':
|
||||
apps = spoc.list_installed()
|
||||
elif list_type == 'online':
|
||||
apps = spoc.list_online()
|
||||
elif list_type == 'updates':
|
||||
apps = spoc.list_updates()
|
||||
else:
|
||||
apps = {}
|
||||
for app_name, app_version in apps.items():
|
||||
print(app_name, app_version)
|
||||
|
||||
def install(app_name, from_file):
|
||||
spoc.install(app_name, from_file)
|
||||
|
||||
def update(app_name, from_file):
|
||||
spoc.update(app_name, from_file)
|
||||
|
||||
def uninstall(app_name):
|
||||
spoc.uninstall(app_name)
|
||||
|
||||
def start(app_name):
|
||||
spoc.start(app_name)
|
||||
|
||||
def stop(app_name):
|
||||
spoc.stop(app_name)
|
||||
|
||||
def status(app_name):
|
||||
print(spoc.status(app_name))
|
||||
|
||||
def set_autostart(app_name, value):
|
||||
enabled = value.lower() in ('1', 'on', 'enable', 'true')
|
||||
spoc.set_autostart(app_name, enabled)
|
||||
|
||||
def start_autostarted():
|
||||
spoc.start_autostarted()
|
||||
|
||||
def stop_all():
|
||||
spoc.stop_all()
|
||||
|
||||
def login(host):
|
||||
username = input('Username: ')
|
||||
password = getpass.getpass()
|
||||
spoc.login(host, username, password)
|
||||
print('Login OK')
|
||||
|
||||
def prune():
|
||||
spoc.prune()
|
||||
|
||||
|
||||
def parse_args(args=None):
|
||||
parser = argparse.ArgumentParser(description='SPOC application manager')
|
||||
subparsers = parser.add_subparsers(dest='action', required=True)
|
||||
|
||||
parser_list = subparsers.add_parser('list')
|
||||
parser_list.set_defaults(action=listing)
|
||||
parser_list.add_argument('type', choices=('installed', 'online', 'updates'),
|
||||
default='installed', const='installed', nargs='?',
|
||||
help='Selected repository or application criteria')
|
||||
|
||||
parser_install = subparsers.add_parser('install')
|
||||
parser_install.set_defaults(action=install)
|
||||
parser_install.add_argument('app', help='Name of the application to install')
|
||||
parser_install.add_argument('--from-file',
|
||||
help='Filename containing the application definition ' \
|
||||
'to be used instead of online repository')
|
||||
|
||||
parser_update = subparsers.add_parser('update')
|
||||
parser_update.set_defaults(action=update)
|
||||
parser_update.add_argument('app', help='Name of the application to update')
|
||||
parser_update.add_argument('--from-file',
|
||||
help='Filename containing the application definition ' \
|
||||
'to be used instead of online repository')
|
||||
|
||||
parser_uninstall = subparsers.add_parser('uninstall')
|
||||
parser_uninstall.set_defaults(action=uninstall)
|
||||
parser_uninstall.add_argument('app', help='Name of the application to uninstall')
|
||||
|
||||
parser_start = subparsers.add_parser('start')
|
||||
parser_start.set_defaults(action=start)
|
||||
parser_start.add_argument('app', help='Name of the application to start')
|
||||
|
||||
parser_stop = subparsers.add_parser('stop')
|
||||
parser_stop.set_defaults(action=stop)
|
||||
parser_stop.add_argument('app', help='Name of the application to stop')
|
||||
|
||||
parser_status = subparsers.add_parser('status')
|
||||
parser_status.set_defaults(action=status)
|
||||
parser_status.add_argument('app', nargs='?', help='Name of the application to check')
|
||||
|
||||
parser_autostart = subparsers.add_parser('autostart')
|
||||
parser_autostart.set_defaults(action=set_autostart)
|
||||
parser_autostart.add_argument('app', help='Name of the application to be automatically started')
|
||||
parser_autostart.add_argument('value', choices=('1', 'on', 'enable', 'true',
|
||||
'0', 'off', 'disable', 'false'),
|
||||
help='Set or unset the applications to be automatically ' \
|
||||
'started after the host boots up')
|
||||
|
||||
parser_start_autostarted = subparsers.add_parser('start-autostarted')
|
||||
parser_start_autostarted.set_defaults(action=start_autostarted)
|
||||
|
||||
parser_stop_all = subparsers.add_parser('stop-all')
|
||||
parser_stop_all.set_defaults(action=stop_all)
|
||||
|
||||
parser_login = subparsers.add_parser('login')
|
||||
parser_login.set_defaults(action=login)
|
||||
parser_login.add_argument('host', help='Hostname of the container registry')
|
||||
|
||||
parser_prune = subparsers.add_parser('prune')
|
||||
parser_prune.set_defaults(action=prune)
|
||||
|
||||
return parser.parse_args(args)
|
||||
|
||||
def main(): # pylint: disable=too-many-branches
|
||||
args = parse_args()
|
||||
try:
|
||||
if args.action is listing:
|
||||
listing(args.type)
|
||||
elif args.action is install:
|
||||
install(args.app, args.from_file)
|
||||
elif args.action is update:
|
||||
update(args.app, args.from_file)
|
||||
elif args.action is uninstall:
|
||||
uninstall(args.app)
|
||||
elif args.action is start:
|
||||
start(args.app)
|
||||
elif args.action is stop:
|
||||
stop(args.app)
|
||||
elif args.action is status:
|
||||
status(args.app)
|
||||
elif args.action is set_autostart:
|
||||
set_autostart(args.app, args.value)
|
||||
elif args.action is start_autostarted:
|
||||
start_autostarted()
|
||||
elif args.action is stop_all:
|
||||
stop_all()
|
||||
elif args.action is login:
|
||||
login(args.host)
|
||||
elif args.action is prune:
|
||||
prune()
|
||||
except spoc.AppError as ex:
|
||||
handle_app_error(ex)
|
||||
except requests.RequestException as ex:
|
||||
handle_repo_error(ex)
|
266
tests/test_app.py
Normal file
266
tests/test_app.py
Normal file
@ -0,0 +1,266 @@
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import patch, call, mock_open
|
||||
|
||||
from spoc import app
|
||||
from spoc import config
|
||||
|
||||
|
||||
TEST_DATA_DIR = os.path.join(os.path.dirname(__file__), 'test_data')
|
||||
with open(os.path.join(TEST_DATA_DIR, 'repository.json'), encoding='utf-8') as f:
|
||||
MOCK_REPODATA = json.load(f)
|
||||
|
||||
MOCK_ENV = 'RAILS_ENV=test\n' \
|
||||
'POSTGRES_PASSWORD=asdf=1234\n' \
|
||||
'SOMEKEY=someval\n'
|
||||
|
||||
MOCK_ENV_DATA = {
|
||||
'RAILS_ENV': 'test',
|
||||
'POSTGRES_PASSWORD': 'asdf=1234',
|
||||
'SOMEKEY': 'someval',
|
||||
}
|
||||
|
||||
|
||||
def test_init():
|
||||
instance = app.App('someapp')
|
||||
|
||||
assert instance.app_name == 'someapp'
|
||||
assert instance.env_file == os.path.join(config.DATA_DIR, 'someapp.env')
|
||||
|
||||
@patch('spoc.repo.get_apps', return_value=MOCK_REPODATA)
|
||||
def test_get_definition(repo_get_apps):
|
||||
instance = app.App('someapp')
|
||||
definition = instance.get_definition()
|
||||
|
||||
assert definition == MOCK_REPODATA['someapp']
|
||||
|
||||
repo_get_apps.assert_called_once()
|
||||
|
||||
@patch('spoc.repo.get_apps')
|
||||
@patch('builtins.open', new_callable=mock_open, read_data=json.dumps(MOCK_REPODATA['someapp']))
|
||||
def test_get_definition_from_file(file_open, repo_get_apps):
|
||||
instance = app.App('someapp')
|
||||
definition = instance.get_definition('somefile')
|
||||
|
||||
assert definition == MOCK_REPODATA['someapp']
|
||||
|
||||
file_open.assert_called_once_with('somefile', encoding='utf-8')
|
||||
repo_get_apps.assert_not_called()
|
||||
|
||||
@patch('spoc.app.App.get_definition', return_value=MOCK_REPODATA['someapp'])
|
||||
@patch('spoc.app.App.get_existing_volumes', return_value=set('somevol'))
|
||||
@patch('spoc.app.App.remove_volumes')
|
||||
@patch('spoc.app.App.create_volumes')
|
||||
@patch('spoc.app.App.read_env_vars')
|
||||
@patch('spoc.app.App.write_env_vars')
|
||||
@patch('spoc.app.App.create_pod')
|
||||
@patch('spoc.app.App.create_containers')
|
||||
def test_install(create_containers, create_pod, write_env_vars, #pylint: disable=too-many-arguments
|
||||
read_env_vars, create_volumes, remove_volumes,
|
||||
get_existing_volumes, get_definition):
|
||||
instance = app.App('someapp')
|
||||
instance.install()
|
||||
|
||||
get_definition.assert_called_once()
|
||||
get_existing_volumes.assert_called_once()
|
||||
remove_volumes.assert_called_once_with(set('somevol'))
|
||||
create_volumes.assert_called_once_with(set(('migrate', 'storage', 'uploads', 'postgres-data')))
|
||||
read_env_vars.assert_not_called()
|
||||
write_env_vars.assert_called_once_with(MOCK_REPODATA['someapp']['environment'])
|
||||
create_pod.assert_called_once_with('0.23.5-210416')
|
||||
create_containers.assert_called_once_with(MOCK_REPODATA['someapp']['containers'])
|
||||
|
||||
@patch('spoc.app.App.get_definition', return_value=MOCK_REPODATA['someapp'])
|
||||
@patch('spoc.app.App.get_existing_volumes', return_value=set(('somevol', 'migrate', 'storage')))
|
||||
@patch('spoc.app.App.remove_volumes')
|
||||
@patch('spoc.app.App.create_volumes')
|
||||
@patch('spoc.app.App.read_env_vars', return_value=MOCK_ENV_DATA)
|
||||
@patch('spoc.app.App.write_env_vars')
|
||||
@patch('spoc.app.App.create_pod')
|
||||
@patch('spoc.app.App.create_containers')
|
||||
def test_update(create_containers, create_pod, write_env_vars, #pylint: disable=too-many-arguments
|
||||
read_env_vars, create_volumes, remove_volumes,
|
||||
get_existing_volumes, get_definition):
|
||||
instance = app.App('someapp')
|
||||
instance.update(from_file='somefile')
|
||||
|
||||
get_definition.assert_called_once()
|
||||
get_existing_volumes.assert_called_once()
|
||||
remove_volumes.assert_called_once_with(set(('somevol',)))
|
||||
create_volumes.assert_called_once_with(set(('uploads', 'postgres-data')))
|
||||
read_env_vars.assert_called_once()
|
||||
expected_env_data = MOCK_REPODATA['someapp']['environment'].copy()
|
||||
expected_env_data.update(MOCK_ENV_DATA)
|
||||
del expected_env_data['SOMEKEY']
|
||||
write_env_vars.assert_called_once_with(expected_env_data)
|
||||
create_pod.assert_called_once_with('0.23.5-210416')
|
||||
create_containers.assert_called_once_with(MOCK_REPODATA['someapp']['containers'])
|
||||
|
||||
@patch('spoc.autostart.set_app')
|
||||
@patch('spoc.app.App.remove_pod')
|
||||
@patch('spoc.app.App.remove_env_vars')
|
||||
@patch('spoc.app.App.get_existing_volumes', return_value=set(('somevol', 'anothervol')))
|
||||
@patch('spoc.app.App.remove_volumes')
|
||||
def test_uninstall(remove_volumes, get_existing_volumes, remove_env_vars, remove_pod, autostart):
|
||||
instance = app.App('someapp')
|
||||
instance.uninstall()
|
||||
|
||||
autostart.assert_called_with('someapp', False)
|
||||
remove_pod.assert_called_once()
|
||||
remove_env_vars.assert_called_once()
|
||||
get_existing_volumes.assert_called_once()
|
||||
remove_volumes.assert_called_once_with(set(('somevol', 'anothervol')))
|
||||
|
||||
@patch('spoc.podman.remove_pod')
|
||||
@patch('spoc.podman.create_pod')
|
||||
def test_create_pod(create_pod, remove_pod):
|
||||
instance = app.App('someapp')
|
||||
instance.create_pod('0.1')
|
||||
|
||||
remove_pod.assert_called_once_with('someapp')
|
||||
create_pod.assert_called_once_with('someapp', '0.1')
|
||||
|
||||
@patch('spoc.podman.remove_pod')
|
||||
def test_remove_pod(remove_pod):
|
||||
instance = app.App('someapp')
|
||||
instance.remove_pod()
|
||||
|
||||
remove_pod.assert_called_once_with('someapp')
|
||||
|
||||
@patch('builtins.open', new_callable=mock_open, read_data=MOCK_ENV)
|
||||
def test_read_env_vars(env_open):
|
||||
instance = app.App('someapp')
|
||||
env_vars = instance.read_env_vars()
|
||||
|
||||
env_file = os.path.join(config.DATA_DIR, 'someapp.env')
|
||||
env_open.assert_called_once_with(env_file, encoding='utf-8')
|
||||
assert env_vars == MOCK_ENV_DATA
|
||||
|
||||
@patch('builtins.open', side_effect=FileNotFoundError('someapp.env'))
|
||||
def test_read_env_vars_filenotfound(env_open):
|
||||
instance = app.App('someapp')
|
||||
env_vars = instance.read_env_vars()
|
||||
|
||||
env_file = os.path.join(config.DATA_DIR, 'someapp.env')
|
||||
env_open.assert_called_once_with(env_file, encoding='utf-8')
|
||||
assert not env_vars
|
||||
|
||||
@patch('os.makedirs')
|
||||
@patch('builtins.open', new_callable=mock_open)
|
||||
def test_write_env_vars(env_open, makedirs):
|
||||
instance = app.App('someapp')
|
||||
instance.write_env_vars(MOCK_ENV_DATA)
|
||||
|
||||
makedirs.assert_called_once_with(config.DATA_DIR, exist_ok=True)
|
||||
env_file = os.path.join(config.DATA_DIR, 'someapp.env')
|
||||
env_open.assert_called_once_with(env_file, 'w', encoding='utf-8')
|
||||
expected_writes = [call(line) for line in MOCK_ENV.splitlines(True)]
|
||||
env_open().write.assert_has_calls(expected_writes, any_order=True)
|
||||
|
||||
@patch('os.unlink')
|
||||
def test_remove_env_vars(unlink):
|
||||
instance = app.App('someapp')
|
||||
instance.remove_env_vars()
|
||||
|
||||
env_file = os.path.join(config.DATA_DIR, 'someapp.env')
|
||||
unlink.assert_called_once_with(env_file)
|
||||
|
||||
@patch('os.unlink', side_effect=FileNotFoundError('someapp.env'))
|
||||
def test_remove_env_vars_filenotfound(unlink):
|
||||
instance = app.App('someapp')
|
||||
instance.remove_env_vars()
|
||||
|
||||
env_file = os.path.join(config.DATA_DIR, 'someapp.env')
|
||||
unlink.assert_called_once_with(env_file)
|
||||
|
||||
@patch('spoc.podman.get_volumes_for_app', return_value={'someapp-vol1', 'someapp-vol2'})
|
||||
def test_get_existing_volumes(get_volume_names):
|
||||
instance = app.App('someapp')
|
||||
volumes = instance.get_existing_volumes()
|
||||
|
||||
get_volume_names.assert_called_once_with('someapp')
|
||||
assert volumes == {'vol1', 'vol2'}
|
||||
|
||||
@patch('spoc.app.App.create_volume')
|
||||
def test_create_volumes(create_volume):
|
||||
instance = app.App('someapp')
|
||||
instance.create_volumes({'vol1', 'vol2'})
|
||||
|
||||
create_volume.assert_has_calls([
|
||||
call('vol1'),
|
||||
call('vol2'),
|
||||
], any_order=True)
|
||||
|
||||
@patch('spoc.app.App.remove_volume')
|
||||
def test_remove_volumes(remove_volume):
|
||||
instance = app.App('someapp')
|
||||
instance.remove_volumes({'vol1', 'vol2'})
|
||||
|
||||
remove_volume.assert_has_calls([
|
||||
call('vol1'),
|
||||
call('vol2'),
|
||||
], any_order=True)
|
||||
|
||||
@patch('spoc.podman.create_volume')
|
||||
def test_create_volume(create_volume):
|
||||
instance = app.App('someapp')
|
||||
instance.create_volume('vol1')
|
||||
|
||||
create_volume.assert_called_once_with('someapp', 'someapp-vol1')
|
||||
|
||||
@patch('spoc.podman.remove_volume')
|
||||
def test_remove_volume(remove_volume):
|
||||
instance = app.App('someapp')
|
||||
instance.remove_volume('vol1')
|
||||
|
||||
remove_volume.assert_called_once_with('someapp-vol1')
|
||||
|
||||
@patch('spoc.app.App.create_container')
|
||||
def test_create_containers(create_container):
|
||||
instance = app.App('someapp')
|
||||
definitions = MOCK_REPODATA['someapp']['containers']
|
||||
instance.create_containers(definitions)
|
||||
|
||||
# Ordered by dependency
|
||||
create_container.assert_has_calls([
|
||||
call('postgres', definitions['postgres'], {'someapp', 'postgres'}),
|
||||
call('someapp', definitions['someapp'], {'someapp', 'postgres'}),
|
||||
])
|
||||
|
||||
@patch('spoc.podman.create_container')
|
||||
def test_create_container(create_container):
|
||||
instance = app.App('someapp')
|
||||
definition = MOCK_REPODATA['someapp']['containers']['someapp']
|
||||
instance.create_container('someapp', definition, {'someapp', 'postgres'})
|
||||
|
||||
env_file = os.path.join(config.DATA_DIR, 'someapp.env')
|
||||
volumes = {'someapp-migrate': '/srv/app/db/migrate',
|
||||
'someapp-storage': '/srv/app/storage',
|
||||
'someapp-uploads': '/srv/app/public/uploads'}
|
||||
create_container.assert_called_once_with('someapp', 'someapp-someapp',
|
||||
'example.com/someapp:0.23.6-210515',
|
||||
env_file=env_file,
|
||||
volumes=volumes,
|
||||
requires={'someapp-postgres'},
|
||||
hosts={'someapp', 'postgres'})
|
||||
|
||||
@patch('spoc.app.App')
|
||||
def test_module_install(instance):
|
||||
app.install('someapp')
|
||||
|
||||
instance.assert_called_once_with('someapp')
|
||||
instance.return_value.install.assert_called_once_with(from_file=None)
|
||||
|
||||
@patch('spoc.app.App')
|
||||
def test_module_update(instance):
|
||||
app.update('someapp', from_file='somefile')
|
||||
|
||||
instance.assert_called_once_with('someapp')
|
||||
instance.return_value.update.assert_called_once_with(from_file='somefile')
|
||||
|
||||
@patch('spoc.app.App')
|
||||
def test_module_uninstall(instance):
|
||||
app.uninstall('someapp')
|
||||
|
||||
instance.assert_called_once_with('someapp')
|
||||
instance.return_value.uninstall.assert_called_once()
|
56
tests/test_autostart.py
Normal file
56
tests/test_autostart.py
Normal file
@ -0,0 +1,56 @@
|
||||
from unittest.mock import patch, call, mock_open
|
||||
|
||||
from spoc import autostart
|
||||
from spoc import config
|
||||
|
||||
@patch('builtins.open', new_callable=mock_open, read_data='someapp\nanotherapp\n')
|
||||
def test_get_apps(file_open):
|
||||
apps = autostart.get_apps()
|
||||
|
||||
file_open.assert_called_once_with(config.AUTOSTART_FILE, encoding='utf-8')
|
||||
assert apps == {'someapp', 'anotherapp'}
|
||||
|
||||
@patch('builtins.open', side_effect=FileNotFoundError('someapp.env'))
|
||||
def test_get_apps_filenotfounderror(file_open):
|
||||
apps = autostart.get_apps()
|
||||
|
||||
file_open.assert_called_once_with(config.AUTOSTART_FILE, encoding='utf-8')
|
||||
assert apps == set()
|
||||
|
||||
@patch('os.makedirs')
|
||||
@patch('spoc.autostart.get_apps', return_value={'someapp'})
|
||||
@patch('builtins.open', new_callable=mock_open)
|
||||
def test_set_app_enable(file_open, get_apps, makedirs):
|
||||
autostart.set_app('anotherapp', True)
|
||||
|
||||
get_apps.assert_called_once()
|
||||
makedirs.assert_called_once_with(config.DATA_DIR, exist_ok=True)
|
||||
file_open.assert_called_once_with(config.AUTOSTART_FILE, 'w', encoding='utf-8')
|
||||
file_open().write.assert_has_calls([
|
||||
call('someapp\n'),
|
||||
call('anotherapp\n'),
|
||||
], any_order=True)
|
||||
|
||||
@patch('os.makedirs')
|
||||
@patch('spoc.autostart.get_apps', return_value={'someapp', 'anotherapp'})
|
||||
@patch('builtins.open', new_callable=mock_open)
|
||||
def test_set_app_disable(file_open, get_apps, makedirs):
|
||||
autostart.set_app('anotherapp', False)
|
||||
|
||||
get_apps.assert_called_once()
|
||||
makedirs.assert_called_once_with(config.DATA_DIR, exist_ok=True)
|
||||
file_open.assert_called_once_with(config.AUTOSTART_FILE, 'w', encoding='utf-8')
|
||||
file_open().write.assert_has_calls([
|
||||
call('someapp\n'),
|
||||
])
|
||||
|
||||
@patch('os.makedirs')
|
||||
@patch('spoc.autostart.get_apps', return_value={'someapp'})
|
||||
@patch('builtins.open', new_callable=mock_open)
|
||||
def test_set_app_nonexistent(file_open, get_apps, makedirs):
|
||||
autostart.set_app('anotherapp', False)
|
||||
|
||||
get_apps.assert_called_once()
|
||||
makedirs.assert_not_called()
|
||||
file_open.assert_not_called()
|
||||
file_open().write.assert_not_called()
|
359
tests/test_cli.py
Normal file
359
tests/test_cli.py
Normal file
@ -0,0 +1,359 @@
|
||||
from argparse import Namespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
import spoc
|
||||
import spoc_cli
|
||||
|
||||
class MockResponse: # pylint: disable=too-few-public-methods
|
||||
def __init__(self, status_code, reason):
|
||||
self.status_code = status_code
|
||||
self.reason = reason
|
||||
|
||||
@pytest.mark.parametrize('exception,expected',[
|
||||
(spoc.AppAlreadyInstalledError('someapp'),
|
||||
'Application someapp is already installed\n'),
|
||||
(spoc.AppNotInstalledError('someapp'),
|
||||
'Application someapp is not installed\n'),
|
||||
(spoc.AppNotInRepoError('someapp'),
|
||||
'Application someapp does not exist in the repository\n'),
|
||||
(spoc.AppNotUpdateableError('someapp'),
|
||||
'Application someapp does not have a newer version to update\n'),
|
||||
])
|
||||
def test_handle_repo_error(exception, expected, capsys):
|
||||
spoc_cli.handle_app_error(exception)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.err == expected
|
||||
|
||||
@pytest.mark.parametrize('exception,expected',[
|
||||
(requests.HTTPError(response=MockResponse(401, 'Unauthorized')),
|
||||
'Invalid username/password'),
|
||||
(requests.HTTPError(response=MockResponse(404, 'Not Found')),
|
||||
'404 Not Found'),
|
||||
(requests.ConnectTimeout(),
|
||||
'requests.exceptions.ConnectTimeout'),
|
||||
])
|
||||
def test_handle_app_error(exception, expected, capsys):
|
||||
spoc_cli.handle_repo_error(exception)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
expected = f'Repository "{spoc.config.REGISTRY_HOST}" cannot be reached due to: {expected}\n'
|
||||
assert captured.err == expected
|
||||
|
||||
@patch('spoc.list_installed', return_value={'anotherapp': '0.1', 'someapp': '0.1'})
|
||||
@patch('spoc.list_online')
|
||||
@patch('spoc.list_updates')
|
||||
def test_listing_installed(list_updates, list_online, list_installed, capsys):
|
||||
spoc_cli.listing('installed')
|
||||
|
||||
list_installed.assert_called_once()
|
||||
list_online.assert_not_called()
|
||||
list_updates.assert_not_called()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == 'anotherapp 0.1\nsomeapp 0.1\n'
|
||||
|
||||
@patch('spoc.list_installed')
|
||||
@patch('spoc.list_online', return_value={'anotherapp': '0.2', 'someapp': '0.2'})
|
||||
@patch('spoc.list_updates')
|
||||
def test_listing_online(list_updates, list_online, list_installed, capsys):
|
||||
spoc_cli.listing('online')
|
||||
|
||||
list_installed.assert_not_called()
|
||||
list_online.assert_called_once()
|
||||
list_updates.assert_not_called()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == 'anotherapp 0.2\nsomeapp 0.2\n'
|
||||
|
||||
@patch('spoc.list_installed')
|
||||
@patch('spoc.list_online')
|
||||
@patch('spoc.list_updates', return_value={'anotherapp': '0.1 -> 0.2', 'someapp': '0.1 -> 0.2'})
|
||||
def test_listing_updates(list_updates, list_online, list_installed, capsys):
|
||||
spoc_cli.listing('updates')
|
||||
|
||||
list_installed.assert_not_called()
|
||||
list_online.assert_not_called()
|
||||
list_updates.assert_called_once()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == 'anotherapp 0.1 -> 0.2\nsomeapp 0.1 -> 0.2\n'
|
||||
|
||||
@patch('spoc.list_installed')
|
||||
@patch('spoc.list_online')
|
||||
@patch('spoc.list_updates')
|
||||
def test_listing_invalid(list_updates, list_online, list_installed, capsys):
|
||||
spoc_cli.listing('invalid')
|
||||
|
||||
list_installed.assert_not_called()
|
||||
list_online.assert_not_called()
|
||||
list_updates.assert_not_called()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert not captured.out
|
||||
|
||||
@patch('spoc.install')
|
||||
def test_install(install):
|
||||
spoc_cli.install('someapp', 'somefile')
|
||||
|
||||
install.assert_called_once_with('someapp', 'somefile')
|
||||
|
||||
@patch('spoc.update')
|
||||
def test_update(update):
|
||||
spoc_cli.update('someapp', None)
|
||||
|
||||
update.assert_called_once_with('someapp', None)
|
||||
|
||||
@patch('spoc.uninstall')
|
||||
def test_uninstall(uninstall):
|
||||
spoc_cli.uninstall('someapp')
|
||||
|
||||
uninstall.assert_called_once_with('someapp')
|
||||
|
||||
@patch('spoc.start')
|
||||
def test_start(start):
|
||||
spoc_cli.start('someapp')
|
||||
|
||||
start.assert_called_once_with('someapp')
|
||||
|
||||
@patch('spoc.stop')
|
||||
def test_stop(stop):
|
||||
spoc_cli.stop('someapp')
|
||||
|
||||
stop.assert_called_once_with('someapp')
|
||||
|
||||
@patch('spoc.status', return_value='STATUS')
|
||||
def test_status(status, capsys):
|
||||
spoc_cli.status('someapp')
|
||||
|
||||
status.assert_called_once_with('someapp')
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == 'STATUS\n'
|
||||
|
||||
@pytest.mark.parametrize('value,expected',[
|
||||
('1', True),
|
||||
('on', True),
|
||||
('Enable', True),
|
||||
('TRUE', True),
|
||||
('0', False),
|
||||
('off', False),
|
||||
('Disable', False),
|
||||
('FALSE', False),
|
||||
('whatever', False),
|
||||
])
|
||||
@patch('spoc.set_autostart')
|
||||
def test_set_autostart(set_autostart, value, expected):
|
||||
spoc_cli.set_autostart('someapp', value)
|
||||
|
||||
set_autostart.assert_called_once_with('someapp', expected)
|
||||
|
||||
@patch('spoc.start_autostarted')
|
||||
def test_start_autostarted(start_autostarted):
|
||||
spoc_cli.start_autostarted()
|
||||
|
||||
start_autostarted.assert_called_once()
|
||||
|
||||
@patch('spoc.stop_all')
|
||||
def test_stop_all(stop_all):
|
||||
spoc_cli.stop_all()
|
||||
|
||||
stop_all.assert_called_once()
|
||||
|
||||
@patch('builtins.input', return_value='someuser')
|
||||
@patch('getpass.getpass', return_value='somepass')
|
||||
@patch('spoc.login')
|
||||
def test_login(login, getpass, nput, capsys):
|
||||
spoc_cli.login('somehost')
|
||||
|
||||
nput.assert_called_once_with('Username: ')
|
||||
getpass.assert_called_once()
|
||||
login.assert_called_once_with('somehost', 'someuser', 'somepass')
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == 'Login OK\n'
|
||||
|
||||
@patch('builtins.input', return_value='someuser')
|
||||
@patch('getpass.getpass', return_value='somepass')
|
||||
@patch('spoc.login', side_effect=requests.ConnectTimeout())
|
||||
def test_login_bad(login, getpass, nput, capsys):
|
||||
with pytest.raises(requests.ConnectTimeout):
|
||||
spoc_cli.login('somehost')
|
||||
|
||||
nput.assert_called_once_with('Username: ')
|
||||
getpass.assert_called_once()
|
||||
login.assert_called_once_with('somehost', 'someuser', 'somepass')
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ''
|
||||
|
||||
@patch('spoc.prune')
|
||||
def test_prune(prune):
|
||||
spoc_cli.prune()
|
||||
|
||||
prune.assert_called_once()
|
||||
|
||||
@patch('sys.argv', ['foo', 'list'])
|
||||
@patch('spoc_cli.listing')
|
||||
def test_main_listing(listing):
|
||||
spoc_cli.main()
|
||||
|
||||
listing.assert_called_once_with('installed')
|
||||
|
||||
@patch('sys.argv', ['foo', 'list', 'online'])
|
||||
@patch('spoc_cli.listing')
|
||||
def test_main_listing_online(listing):
|
||||
spoc_cli.main()
|
||||
|
||||
listing.assert_called_once_with('online')
|
||||
|
||||
@patch('sys.argv', ['foo', 'install', 'someapp'])
|
||||
@patch('spoc_cli.install')
|
||||
def test_main_install(install):
|
||||
spoc_cli.main()
|
||||
|
||||
install.assert_called_once_with('someapp', None)
|
||||
|
||||
@patch('sys.argv', ['foo', 'update', '--from-file', 'somefile', 'someapp'])
|
||||
@patch('spoc_cli.update')
|
||||
def test_main_update(update):
|
||||
spoc_cli.main()
|
||||
|
||||
update.assert_called_once_with('someapp', 'somefile')
|
||||
|
||||
@patch('sys.argv', ['foo', 'uninstall', 'someapp'])
|
||||
@patch('spoc_cli.uninstall')
|
||||
def test_main_uninstall(uninstall):
|
||||
spoc_cli.main()
|
||||
|
||||
uninstall.assert_called_once_with('someapp')
|
||||
|
||||
@patch('sys.argv', ['foo', 'start', 'someapp'])
|
||||
@patch('spoc_cli.start')
|
||||
def test_main_start(start):
|
||||
spoc_cli.main()
|
||||
|
||||
start.assert_called_once_with('someapp')
|
||||
|
||||
@patch('sys.argv', ['foo', 'stop', 'someapp'])
|
||||
@patch('spoc_cli.stop')
|
||||
def test_main_stop(stop):
|
||||
spoc_cli.main()
|
||||
|
||||
stop.assert_called_once_with('someapp')
|
||||
|
||||
@patch('sys.argv', ['foo', 'status', 'someapp'])
|
||||
@patch('spoc_cli.status')
|
||||
def test_main_status(status):
|
||||
spoc_cli.main()
|
||||
|
||||
status.assert_called_once_with('someapp')
|
||||
|
||||
@patch('sys.argv', ['foo', 'status'])
|
||||
@patch('spoc_cli.status')
|
||||
def test_main_status_all(status):
|
||||
spoc_cli.main()
|
||||
|
||||
status.assert_called_once_with(None)
|
||||
|
||||
@patch('sys.argv', ['foo', 'autostart', 'someapp', 'on'])
|
||||
@patch('spoc_cli.set_autostart')
|
||||
def test_main_autostart(autostart):
|
||||
spoc_cli.main()
|
||||
|
||||
autostart.assert_called_once_with('someapp', 'on')
|
||||
|
||||
@patch('sys.argv', ['foo', 'start-autostarted'])
|
||||
@patch('spoc_cli.start_autostarted')
|
||||
def test_main_start_autostarted(start_autostarted):
|
||||
spoc_cli.main()
|
||||
|
||||
start_autostarted.assert_called_once()
|
||||
|
||||
@patch('sys.argv', ['foo', 'stop-all'])
|
||||
@patch('spoc_cli.stop_all')
|
||||
def test_main_stop_all(stop_all):
|
||||
spoc_cli.main()
|
||||
|
||||
stop_all.assert_called_once()
|
||||
|
||||
|
||||
@patch('sys.argv', ['foo', 'login', 'example.com'])
|
||||
@patch('spoc_cli.login')
|
||||
def test_main_login(login):
|
||||
spoc_cli.main()
|
||||
|
||||
login.assert_called_once_with('example.com')
|
||||
|
||||
@patch('sys.argv', ['foo', 'prune'])
|
||||
@patch('spoc_cli.prune')
|
||||
def test_main_prune(prune):
|
||||
spoc_cli.main()
|
||||
|
||||
prune.assert_called_once()
|
||||
|
||||
@patch('spoc_cli.parse_args', return_value=Namespace(action=None))
|
||||
@patch('spoc_cli.listing')
|
||||
@patch('spoc_cli.install')
|
||||
@patch('spoc_cli.update')
|
||||
@patch('spoc_cli.uninstall')
|
||||
@patch('spoc_cli.start')
|
||||
@patch('spoc_cli.stop')
|
||||
@patch('spoc_cli.status')
|
||||
@patch('spoc_cli.start_autostarted')
|
||||
@patch('spoc_cli.stop_all')
|
||||
@patch('spoc_cli.login')
|
||||
@patch('spoc_cli.prune')
|
||||
def test_main_invalid(prune, login, stop_all, start_autostarted, # pylint: disable=too-many-arguments
|
||||
status, stop, start, uninstall, update, install, listing, parse_args):
|
||||
spoc_cli.main()
|
||||
|
||||
parse_args.assert_called_once()
|
||||
listing.assert_not_called()
|
||||
install.assert_not_called()
|
||||
update.assert_not_called()
|
||||
uninstall.assert_not_called()
|
||||
start.assert_not_called()
|
||||
stop.assert_not_called()
|
||||
status.assert_not_called()
|
||||
start_autostarted.assert_not_called()
|
||||
stop_all.assert_not_called()
|
||||
login.assert_not_called()
|
||||
prune.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize('argv', [
|
||||
['list', 'invalid'],
|
||||
['install'],
|
||||
['update'],
|
||||
['uninstall'],
|
||||
['start'],
|
||||
['stop'],
|
||||
['autostart'],
|
||||
['autostart', 'someapp'],
|
||||
['login'],
|
||||
['invalid'],
|
||||
[],
|
||||
])
|
||||
def test_main_systemexit(argv):
|
||||
argv.insert(0, 'foo')
|
||||
with patch('sys.argv', argv):
|
||||
with pytest.raises(SystemExit):
|
||||
spoc_cli.main()
|
||||
|
||||
@patch('sys.argv', ['foo', 'start', 'someapp'])
|
||||
@patch('spoc_cli.start', side_effect=spoc.AppNotInstalledError('someapp'))
|
||||
@patch('spoc_cli.handle_app_error')
|
||||
def test_main_apperror(handle_app_error, start):
|
||||
spoc_cli.main()
|
||||
|
||||
start.assert_called_once()
|
||||
handle_app_error.assert_called_once()
|
||||
|
||||
@patch('sys.argv', ['foo', 'login', 'somehost'])
|
||||
@patch('spoc_cli.login', side_effect=requests.HTTPError(response=MockResponse(401, 'Unauthorized')))
|
||||
@patch('spoc_cli.handle_repo_error')
|
||||
def test_main_repoerror(handle_repo_error, login):
|
||||
spoc_cli.main()
|
||||
|
||||
login.assert_called_once()
|
||||
handle_repo_error.assert_called_once()
|
34
tests/test_config.py
Normal file
34
tests/test_config.py
Normal file
@ -0,0 +1,34 @@
|
||||
from unittest.mock import mock_open, patch
|
||||
|
||||
from spoc import config
|
||||
|
||||
@patch('builtins.open', new_callable=mock_open,
|
||||
read_data='{"auths": {"example.com": {"auth": "c29tZXVzZXI6c29tZXBhc3N3b3Jk"}}}')
|
||||
def test_read_auth(auth_open):
|
||||
config.read_auth()
|
||||
|
||||
auth_open.assert_called_once_with(config.REGISTRY_AUTH_FILE, encoding='utf-8')
|
||||
assert config.REGISTRY_HOST == 'example.com'
|
||||
assert config.REGISTRY_AUTH == ('someuser', 'somepassword')
|
||||
assert config.REPO_FILE_URL == 'https://example.com/repository.json'
|
||||
|
||||
@patch('builtins.open', side_effect=FileNotFoundError('auth.json'))
|
||||
def test_read_auth_fallback(auth_open):
|
||||
config.read_auth()
|
||||
|
||||
auth_open.assert_called_once_with(config.REGISTRY_AUTH_FILE, encoding='utf-8')
|
||||
assert config.REGISTRY_HOST == 'localhost'
|
||||
assert config.REGISTRY_AUTH is None
|
||||
assert config.REPO_FILE_URL == 'https://localhost/repository.json'
|
||||
|
||||
@patch('builtins.open', new_callable=mock_open)
|
||||
def test_write_auth(auth_open):
|
||||
config.write_auth('example.org', 'user', 'anotherpwd')
|
||||
|
||||
auth_open.assert_called_once_with(config.REGISTRY_AUTH_FILE, 'w', encoding='utf-8')
|
||||
expected_data = '{"auths": {"example.org": {"auth": "dXNlcjphbm90aGVycHdk"}}}'
|
||||
auth_open().write.assert_called_once_with(expected_data)
|
||||
|
||||
assert config.REGISTRY_HOST == 'example.org'
|
||||
assert config.REGISTRY_AUTH == ('user', 'anotherpwd')
|
||||
assert config.REPO_FILE_URL == 'https://example.org/repository.json'
|
113
tests/test_data/podman_pod_ps.json
Normal file
113
tests/test_data/podman_pod_ps.json
Normal file
@ -0,0 +1,113 @@
|
||||
[
|
||||
{
|
||||
"Cgroup": "/libpod_parent",
|
||||
"Containers": [
|
||||
{
|
||||
"Id": "59cacababc9ea7f0a7f4ad28c67227fdd6acc57a06b0b289390647e45152857b",
|
||||
"Names": "yetanotherapp-cnt1",
|
||||
"Status": "running"
|
||||
},
|
||||
{
|
||||
"Id": "720dabf6edc271c52ea22535398966db094ab5eff1de894e6beb7c68e4657847",
|
||||
"Names": "4faa6b9ad5aa-infra",
|
||||
"Status": "running"
|
||||
},
|
||||
{
|
||||
"Id": "7af90eef4b48f20dabdaaec90c6c7583fea6800d2433ef7879b805d51b81bfc4",
|
||||
"Names": "yetanotherapp-cnt2",
|
||||
"Status": "running"
|
||||
}
|
||||
],
|
||||
"Created": "2021-07-06T09:19:24.609538926+02:00",
|
||||
"Id": "4faa6b9ad5aa28b915a8ac967a01d9c3317be3a3bfc198b0681636399c19372e",
|
||||
"InfraId": "720dabf6edc271c52ea22535398966db094ab5eff1de894e6beb7c68e4657847",
|
||||
"Name": "yetanotherapp",
|
||||
"Namespace": "",
|
||||
"Networks": [
|
||||
"podman"
|
||||
],
|
||||
"Status": "Running",
|
||||
"Labels": {
|
||||
"spoc.app": "yetanotherapp",
|
||||
"spoc.version": "0.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Cgroup": "/libpod_parent",
|
||||
"Containers": [
|
||||
{
|
||||
"Id": "798cae491ef9025db809c261fb1169f5cc09526119d252340b9d64f0fce37be1",
|
||||
"Names": "97f0c135887c-infra",
|
||||
"Status": "running"
|
||||
},
|
||||
{
|
||||
"Id": "9d02724a74d929818d08395b376d960b3dd30556738bc43e96f50a27f355b9a5",
|
||||
"Names": "anotherapp-cnt2",
|
||||
"Status": "configured"
|
||||
},
|
||||
{
|
||||
"Id": "b5833a8da89d40824fdb4f2b779d24135d07452f5bfa583f96e369c5953ee286",
|
||||
"Names": "anotherapp-cnt1",
|
||||
"Status": "stopped"
|
||||
}
|
||||
],
|
||||
"Created": "2021-07-06T08:47:06.389299933+02:00",
|
||||
"Id": "97f0c135887c8ef6eccf4a37fbcc1e26a0f3c02e73de8edaa959bfba9592b1dd",
|
||||
"InfraId": "798cae491ef9025db809c261fb1169f5cc09526119d252340b9d64f0fce37be1",
|
||||
"Name": "anotherapp",
|
||||
"Namespace": "",
|
||||
"Networks": [
|
||||
"podman"
|
||||
],
|
||||
"Status": "Degraded",
|
||||
"Labels": {
|
||||
"spoc.app": "anotherapp",
|
||||
"spoc.version": "0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Cgroup": "/libpod_parent",
|
||||
"Containers": [
|
||||
{
|
||||
"Id": "151e1e35083391eea41605db364b7e15fde7047a6119feffcd06984671a5c991",
|
||||
"Names": "be0a8d0ab749-infra",
|
||||
"Status": "running"
|
||||
}
|
||||
],
|
||||
"Created": "2021-07-03T20:01:37.63866841+02:00",
|
||||
"Id": "be0a8d0ab749b3c089f72a844700b76aafa541fffca5186865bef185fc1914a0",
|
||||
"InfraId": "151e1e35083391eea41605db364b7e15fde7047a6119feffcd06984671a5c991",
|
||||
"Name": "notmyapp",
|
||||
"Namespace": "",
|
||||
"Networks": [
|
||||
"podman"
|
||||
],
|
||||
"Status": "Running",
|
||||
"Labels": {
|
||||
|
||||
}
|
||||
},
|
||||
{
|
||||
"Cgroup": "/libpod_parent",
|
||||
"Containers": [
|
||||
{
|
||||
"Id": "0897891f6e7308903c4316ce80f569320176a38d5bc4de1fbf4b2323c1a51fcb",
|
||||
"Names": "18c00febc93c-infra",
|
||||
"Status": "configured"
|
||||
}
|
||||
],
|
||||
"Created": "2021-07-03T13:29:36.975071665+02:00",
|
||||
"Id": "18c00febc93ca105b5d83247e7b4a0b2184c82262d421f2c857dbf155dbe97e8",
|
||||
"InfraId": "0897891f6e7308903c4316ce80f569320176a38d5bc4de1fbf4b2323c1a51fcb",
|
||||
"Name": "someapp",
|
||||
"Namespace": "",
|
||||
"Networks": [
|
||||
"podman"
|
||||
],
|
||||
"Status": "Created",
|
||||
"Labels": {
|
||||
"spoc.app": "someapp",
|
||||
"spoc.version": "0.1"
|
||||
}
|
||||
}
|
||||
]
|
28
tests/test_data/podman_volume_ls.json
Normal file
28
tests/test_data/podman_volume_ls.json
Normal file
@ -0,0 +1,28 @@
|
||||
[
|
||||
{
|
||||
"Name": "someapp-conf",
|
||||
"Driver": "local",
|
||||
"Mountpoint": "/var/lib/containers/storage/volumes/someapp-conf/_data",
|
||||
"CreatedAt": "2021-07-04T18:22:44.758466689+02:00",
|
||||
"Labels": {
|
||||
"spoc.app": "someapp"
|
||||
},
|
||||
"Scope": "local",
|
||||
"Options": {
|
||||
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "someapp-data",
|
||||
"Driver": "local",
|
||||
"Mountpoint": "/var/lib/containers/storage/volumes/someapp-data/_data",
|
||||
"CreatedAt": "2021-07-03T13:22:11.455581712+02:00",
|
||||
"Labels": {
|
||||
"spoc.app": "someapp"
|
||||
},
|
||||
"Scope": "local",
|
||||
"Options": {
|
||||
|
||||
}
|
||||
}
|
||||
]
|
69
tests/test_data/repository.json
Normal file
69
tests/test_data/repository.json
Normal file
@ -0,0 +1,69 @@
|
||||
{
|
||||
"someapp": {
|
||||
"version": "0.23.5-210416",
|
||||
"meta": {
|
||||
"title": "Some Application",
|
||||
"desc-cs": "Platforma pro účast občanů",
|
||||
"desc-en": "Platform for citizen participation",
|
||||
"license": "GPL"
|
||||
},
|
||||
"environment": {
|
||||
"RAILS_ENV": "production",
|
||||
"RAILS_LOG_TO_STDOUT": "1",
|
||||
"POSTGRES_USER": "someapp",
|
||||
"POSTGRES_PASSWORD": "someapp",
|
||||
"POSTGRES_DB": "someapp",
|
||||
"POSTGRES_HOST": "postgres",
|
||||
"DATABASE_URL": "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}"
|
||||
},
|
||||
"containers": {
|
||||
"someapp": {
|
||||
"image": "example.com/someapp:0.23.6-210515",
|
||||
"requires": [
|
||||
"postgres"
|
||||
],
|
||||
"volumes": {
|
||||
"migrate": "/srv/app/db/migrate",
|
||||
"storage": "/srv/app/storage",
|
||||
"uploads": "/srv/app/public/uploads"
|
||||
}
|
||||
},
|
||||
"postgres": {
|
||||
"image": "docker.io/postgres:12-alpine",
|
||||
"volumes": {
|
||||
"postgres-data": "/var/lib/postgresql/data"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"anotherapp": {
|
||||
"version": "1.0.3-210106",
|
||||
"meta": {
|
||||
"title": "Another Application",
|
||||
"desc-cs": "Řízení humanítární činnosti",
|
||||
"desc-en": "Management of humanitarian activities",
|
||||
"license": "GPL"
|
||||
},
|
||||
"containers": {
|
||||
"anotherapp": {
|
||||
"image": "example.com/anotherapp:1.0.3-210106",
|
||||
"requires": [
|
||||
"postgres"
|
||||
],
|
||||
"volumes": {
|
||||
"conf": "/srv/web2py/applications/app/models",
|
||||
"data-databases": "/srv/web2py/applications/app/databases",
|
||||
"data-errors": "/srv/web2py/applications/app/errors",
|
||||
"data-sessions": "/srv/web2py/applications/app/sessions",
|
||||
"data-uploads": "/srv/web2py/applications/app/uploads"
|
||||
}
|
||||
},
|
||||
"postgres": {
|
||||
"image": "docker.io/postgres:12-alpine",
|
||||
"volumes": {
|
||||
"postgres-data": "/var/lib/postgresql/data"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
92
tests/test_depsolver.py
Normal file
92
tests/test_depsolver.py
Normal file
@ -0,0 +1,92 @@
|
||||
import pytest
|
||||
|
||||
from spoc import depsolver
|
||||
|
||||
def test_circulardependencyerror():
|
||||
ex = depsolver.CircularDependencyError({'dep1': {'dep2'}, 'dep2': {'dep1'}})
|
||||
ex_str = str(ex)
|
||||
|
||||
assert ex.deps == {'dep1': {'dep2'}, 'dep2': {'dep1'}}
|
||||
assert ex_str == 'Dependency resolution failed due to circular dependency.\n' \
|
||||
'Unresolved dependencies:\n' \
|
||||
' dep1 => {\'dep2\'}\n' \
|
||||
' dep2 => {\'dep1\'}'
|
||||
|
||||
def test_missingdependencyerror():
|
||||
ex = depsolver.MissingDependencyError({'dep1': {'dep2'}}, {'dep2'})
|
||||
ex_str = str(ex)
|
||||
|
||||
assert ex.deps == {'dep1': {'dep2'}}
|
||||
assert ex.missing == {'dep2'}
|
||||
assert ex_str == 'Dependency resolution failed due to missing dependency.\n' \
|
||||
'Missing dependencies:\n' \
|
||||
' {\'dep2\'}\n' \
|
||||
'Unresolved dependencies:\n' \
|
||||
' dep1 => {\'dep2\'}'
|
||||
|
||||
def test_depsolver():
|
||||
solver = depsolver.DepSolver()
|
||||
|
||||
assert not solver.unresolved
|
||||
|
||||
solver.add('dep1', ['dep2', 'dep3'])
|
||||
solver.add('dep2', ['dep3', 'dep3'])
|
||||
solver.add('dep3', [])
|
||||
|
||||
assert solver.unresolved == {
|
||||
'dep1': {'dep2', 'dep3'},
|
||||
'dep2': {'dep3'},
|
||||
'dep3': set(),
|
||||
}
|
||||
|
||||
resolved = solver.solve()
|
||||
|
||||
assert resolved == ['dep3', 'dep2', 'dep1']
|
||||
|
||||
def test_depsolver_complex():
|
||||
solver = depsolver.DepSolver()
|
||||
|
||||
solver.add('dep1', ['dep8', 'dep12'])
|
||||
solver.add('dep2', ['dep10'])
|
||||
solver.add('dep3', [])
|
||||
solver.add('dep4', ['dep9'])
|
||||
solver.add('dep5', ['dep1', 'dep6', 'dep8'])
|
||||
solver.add('dep6', ['dep2','dep10', 'dep13', 'dep14'])
|
||||
solver.add('dep7', ['dep9'])
|
||||
solver.add('dep8', ['dep2', 'dep12'])
|
||||
solver.add('dep9', [])
|
||||
solver.add('dep10', ['dep9'])
|
||||
solver.add('dep11', ['dep2', 'dep14'])
|
||||
solver.add('dep12', ['dep7'])
|
||||
solver.add('dep13', ['dep9'])
|
||||
solver.add('dep14', ['dep4'])
|
||||
|
||||
resolved = solver.solve()
|
||||
|
||||
# Order within the same batch (i.e. items not depending on each other) can be random
|
||||
assert list(sorted(resolved[:2])) == ['dep3', 'dep9']
|
||||
assert list(sorted(resolved[2:9])) == ['dep10', 'dep12', 'dep13', 'dep14',
|
||||
'dep2', 'dep4', 'dep7']
|
||||
assert list(sorted(resolved[9:12])) == ['dep11', 'dep6', 'dep8']
|
||||
assert list(sorted(resolved[12:])) == ['dep1', 'dep5']
|
||||
|
||||
def test_depsolver_circular():
|
||||
solver = depsolver.DepSolver()
|
||||
|
||||
solver.add('dep1', ['dep2', 'dep3'])
|
||||
solver.add('dep2', ['dep3'])
|
||||
solver.add('dep3', ['dep4'])
|
||||
solver.add('dep4', ['dep1'])
|
||||
|
||||
with pytest.raises(depsolver.CircularDependencyError):
|
||||
solver.solve()
|
||||
|
||||
def test_depsolver_missing():
|
||||
solver = depsolver.DepSolver()
|
||||
|
||||
solver.add('dep1', ['dep2', 'dep3'])
|
||||
solver.add('dep2', ['dep3'])
|
||||
solver.add('dep4', ['dep1'])
|
||||
|
||||
with pytest.raises(depsolver.MissingDependencyError):
|
||||
solver.solve()
|
110
tests/test_flock.py
Normal file
110
tests/test_flock.py
Normal file
@ -0,0 +1,110 @@
|
||||
import errno
|
||||
import fcntl
|
||||
from unittest.mock import call, patch, mock_open
|
||||
|
||||
import pytest
|
||||
|
||||
from spoc import config
|
||||
from spoc import flock
|
||||
|
||||
@flock.locked()
|
||||
def mock_func():
|
||||
pass
|
||||
|
||||
@patch('builtins.open', new_callable=mock_open, read_data='foo\0arg1\0arg2\n'.encode())
|
||||
def test_print_lock(cmdline_open, capsys):
|
||||
flock.print_lock('123')
|
||||
|
||||
cmdline_open.assert_called_once_with('/proc/123/cmdline', 'rb')
|
||||
captured = capsys.readouterr()
|
||||
assert captured.err == 'Waiting for lock currently held by process 123 - foo arg1 arg2\n'
|
||||
|
||||
@patch('spoc.flock.print_lock')
|
||||
@patch('fcntl.flock')
|
||||
@patch('time.sleep')
|
||||
@patch('os.getpid', return_value=1234)
|
||||
@patch('builtins.open', new_callable=mock_open)
|
||||
def test_locked_success(lock_open, getpid, sleep, fcntl_flock, print_lock):
|
||||
mock_func()
|
||||
|
||||
lock_open.assert_has_calls([
|
||||
call(config.LOCK_FILE, 'a', encoding='utf-8'),
|
||||
call().__enter__(),
|
||||
call().__exit__(None, None, None),
|
||||
call(config.LOCK_FILE, 'r+', encoding='utf-8'),
|
||||
call().__enter__(),
|
||||
call().truncate(),
|
||||
call().write('1234'),
|
||||
call().flush(),
|
||||
call().__exit__(None, None, None),
|
||||
])
|
||||
|
||||
fcntl_flock.assert_called_once_with(lock_open(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
sleep.assert_not_called()
|
||||
getpid.assert_called_once()
|
||||
print_lock.assert_not_called()
|
||||
|
||||
@patch('spoc.flock.print_lock')
|
||||
@patch('fcntl.flock')
|
||||
@patch('time.sleep')
|
||||
@patch('os.getpid', return_value=5678)
|
||||
@patch('builtins.open', new_callable=mock_open, read_data='1234')
|
||||
def test_locked_fail(lock_open, getpid, sleep, fcntl_flock, print_lock):
|
||||
fcntl_flock.side_effect = [
|
||||
OSError(errno.EAGAIN, 'in use'),
|
||||
OSError(errno.EAGAIN, 'in use'),
|
||||
None,
|
||||
]
|
||||
|
||||
mock_func()
|
||||
|
||||
lock_open.assert_has_calls([
|
||||
call(config.LOCK_FILE, 'a', encoding='utf-8'),
|
||||
call().__enter__(),
|
||||
call().__exit__(None, None, None),
|
||||
call(config.LOCK_FILE, 'r+', encoding='utf-8'),
|
||||
call().__enter__(),
|
||||
call().read(),
|
||||
call().seek(0),
|
||||
call().truncate(),
|
||||
call().write('5678'),
|
||||
call().flush(),
|
||||
call().__exit__(None, None, None),
|
||||
])
|
||||
|
||||
expected_fcntl_flock_call = call(lock_open(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
assert fcntl_flock.call_args_list.count(expected_fcntl_flock_call) == 3
|
||||
expected_sleep_call = call(0.1)
|
||||
assert sleep.call_args_list.count(expected_sleep_call) == 2
|
||||
getpid.assert_called_once()
|
||||
print_lock.assert_called_once_with('1234')
|
||||
|
||||
@patch('spoc.flock.print_lock')
|
||||
@patch('fcntl.flock', side_effect=OSError(errno.EBADF, 'nope'))
|
||||
@patch('time.sleep')
|
||||
@patch('os.getpid', return_value=5678)
|
||||
@patch('builtins.open', new_callable=mock_open, read_data='1234')
|
||||
def test_locked_error(lock_open, getpid, sleep, fcntl_flock, print_lock):
|
||||
with pytest.raises(OSError):
|
||||
mock_func()
|
||||
|
||||
# Last call is
|
||||
# call().__exit__(<class 'OSError'>, OSError(9, 'nope'), <traceback object at 0xaddress>)
|
||||
# The exception can be passed by the context manager above and checked as follows
|
||||
# call().__exit__(ex.type, ex.value, ex.tb.tb_next.tb_next.tb_next)
|
||||
# but it may by CPython specific, and frankly, that tb_next chain looks horrible.
|
||||
# hence checking just the method and comparing the args with themselves
|
||||
last_exit_call_args = lock_open().__exit__.call_args_list[-1][0]
|
||||
lock_open.assert_has_calls([
|
||||
call(config.LOCK_FILE, 'a', encoding='utf-8'),
|
||||
call().__enter__(),
|
||||
call().__exit__(None, None, None),
|
||||
call(config.LOCK_FILE, 'r+', encoding='utf-8'),
|
||||
call().__enter__(),
|
||||
call().__exit__(*last_exit_call_args),
|
||||
])
|
||||
|
||||
fcntl_flock.assert_called_once_with(lock_open(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
sleep.assert_not_called()
|
||||
getpid.assert_not_called()
|
||||
print_lock.assert_not_called()
|
165
tests/test_podman.py
Normal file
165
tests/test_podman.py
Normal file
@ -0,0 +1,165 @@
|
||||
import subprocess
|
||||
import os
|
||||
from unittest.mock import patch, call
|
||||
|
||||
from spoc import podman
|
||||
|
||||
TEST_DATA_DIR = os.path.join(os.path.dirname(__file__), 'test_data')
|
||||
|
||||
@patch('subprocess.run')
|
||||
def test_run(run):
|
||||
process = podman.run(['foo', 'bar'])
|
||||
|
||||
run.assert_called_once_with(['foo', 'bar'], check=True, env=podman.ENV)
|
||||
assert process == run.return_value
|
||||
|
||||
@patch('subprocess.run')
|
||||
def test_out(run):
|
||||
run.return_value.stdout = 'RESULT\n'
|
||||
output = podman.out(['foo', 'bar'], arg1=123, arg2=True)
|
||||
|
||||
run.assert_called_once_with(['foo', 'bar'], stdout=subprocess.PIPE, text=True, check=True,
|
||||
env=podman.ENV, arg1=123, arg2=True)
|
||||
assert output == 'RESULT'
|
||||
|
||||
@patch('spoc.podman.out', return_value=' 0 1000 1\n 1 100000 65536')
|
||||
def test_get_subuidgid(out):
|
||||
subuidgid = podman.get_subuidgid()
|
||||
|
||||
assert subuidgid == (100000, 100000)
|
||||
out.assert_has_calls([
|
||||
call(['su', '-', 'spoc', '-c', 'podman unshare cat /proc/self/uid_map']),
|
||||
call(['su', '-', 'spoc', '-c', 'podman unshare cat /proc/self/gid_map']),
|
||||
])
|
||||
|
||||
@patch('spoc.podman.out', return_value='')
|
||||
def test_get_subuidgid_no_id(out):
|
||||
subuidgid = podman.get_subuidgid()
|
||||
|
||||
assert subuidgid == (0, 0)
|
||||
assert out.call_count == 2
|
||||
|
||||
@patch('spoc.podman.out')
|
||||
def test_get_apps(out):
|
||||
with open(os.path.join(TEST_DATA_DIR, 'podman_pod_ps.json'), encoding='utf-8') as f:
|
||||
out.return_value = f.read()
|
||||
|
||||
pods = podman.get_apps()
|
||||
|
||||
expected_cmd = ['podman', 'pod', 'ps', '--format', 'json']
|
||||
out.assert_called_once_with(expected_cmd)
|
||||
assert pods == {'someapp': '0.1', 'anotherapp': '0.2', 'yetanotherapp': '0.3'}
|
||||
|
||||
@patch('spoc.podman.out')
|
||||
def test_get_volumes_for_app(out):
|
||||
with open(os.path.join(TEST_DATA_DIR, 'podman_volume_ls.json'), encoding='utf-8') as f:
|
||||
out.return_value = f.read()
|
||||
|
||||
volumes = podman.get_volumes_for_app('someapp')
|
||||
|
||||
expected_cmd = ['podman', 'volume', 'ls', '--filter', 'label=spoc.app=someapp',
|
||||
'--format', 'json']
|
||||
out.assert_called_once_with(expected_cmd)
|
||||
assert volumes == {'someapp-conf', 'someapp-data'}
|
||||
|
||||
@patch('spoc.podman.run')
|
||||
def test_start_pod(run):
|
||||
podman.start_pod('someapp')
|
||||
|
||||
expected_cmd = ['podman', 'pod', 'start', 'someapp']
|
||||
run.assert_called_once_with(expected_cmd)
|
||||
|
||||
@patch('spoc.podman.run')
|
||||
def test_stop_pod(run):
|
||||
podman.stop_pod('someapp')
|
||||
|
||||
expected_cmd = ['podman', 'pod', 'stop', '--ignore', 'someapp']
|
||||
run.assert_called_once_with(expected_cmd)
|
||||
|
||||
@patch('spoc.podman.out')
|
||||
def test_get_pod_status(out):
|
||||
out.return_value = 'RESULT'
|
||||
status = podman.get_pod_status('someapp')
|
||||
|
||||
expected_cmd = ['podman', 'pod', 'ps', '--filter', 'label=spoc.app=someapp']
|
||||
out.assert_called_once_with(expected_cmd)
|
||||
assert status == 'RESULT'
|
||||
|
||||
@patch('spoc.podman.out')
|
||||
def test_get_pod_status_all(out):
|
||||
out.return_value = 'RESULT'
|
||||
status = podman.get_pod_status()
|
||||
|
||||
expected_cmd = ['podman', 'pod', 'ps', '--filter', 'label=spoc.app']
|
||||
out.assert_called_once_with(expected_cmd)
|
||||
assert status == 'RESULT'
|
||||
|
||||
@patch('spoc.podman.run')
|
||||
@patch('spoc.podman.get_subuidgid', return_value=(100000, 100000))
|
||||
def test_create_volume(get_subuidgid, run):
|
||||
podman.create_volume('someapp', 'someapp-vol')
|
||||
|
||||
expected_cmd = ['podman', 'volume', 'create',
|
||||
'--opt', 'o=uid=100000,gid=100000',
|
||||
'--label', 'spoc.app=someapp', 'someapp-vol']
|
||||
get_subuidgid.assert_called_once()
|
||||
run.assert_called_once_with(expected_cmd)
|
||||
|
||||
@patch('spoc.podman.run')
|
||||
def test_remove_volume(run):
|
||||
podman.remove_volume('someapp-vol')
|
||||
|
||||
expected_cmd = ['podman', 'volume', 'rm', 'someapp-vol']
|
||||
run.assert_called_once_with(expected_cmd)
|
||||
|
||||
@patch('spoc.podman.run')
|
||||
def test_create_pod(run):
|
||||
podman.create_pod('someapp', '0.1')
|
||||
|
||||
expected_cmd = ['podman', 'pod', 'create', '--name', 'someapp',
|
||||
'--subuidname', 'spoc', '--subgidname', 'spoc',
|
||||
'--label', 'spoc.app=someapp', '--label', 'spoc.version=0.1']
|
||||
run.assert_called_once_with(expected_cmd)
|
||||
|
||||
@patch('spoc.podman.stop_pod')
|
||||
@patch('spoc.podman.run')
|
||||
def test_remove_pod(run, stop_pod):
|
||||
podman.remove_pod('someapp')
|
||||
|
||||
stop_pod.assert_called_once_with('someapp')
|
||||
expected_cmd = ['podman', 'pod', 'rm', '--ignore', 'someapp']
|
||||
run.assert_called_once_with(expected_cmd)
|
||||
|
||||
@patch('spoc.podman.run')
|
||||
def test_create_container(run):
|
||||
podman.create_container('someapp', 'someapp-cnt', 'example.com/someapp:0.23.6-210515',
|
||||
env_file='/var/lib/spoc/someapp.env',
|
||||
volumes={'someapp-srv': '/srv', 'someapp-mnt': '/mnt'},
|
||||
requires={'someapp-cnt3', 'someapp-cnt2'},
|
||||
hosts={'cnt2', 'cnt3', 'cnt'})
|
||||
|
||||
expected_cmd = ['podman', 'container', 'create', '--name', 'someapp-cnt', '--pod', 'someapp',
|
||||
'--subuidname', 'spoc', '--subgidname', 'spoc',
|
||||
'--restart', 'unless-stopped', '--env-file', '/var/lib/spoc/someapp.env',
|
||||
'--requires', 'someapp-cnt2,someapp-cnt3', '--volume', 'someapp-mnt:/mnt',
|
||||
'--volume', 'someapp-srv:/srv', '--add-host', 'cnt:127.0.0.1',
|
||||
'--add-host', 'cnt2:127.0.0.1', '--add-host', 'cnt3:127.0.0.1',
|
||||
'example.com/someapp:0.23.6-210515']
|
||||
run.assert_called_once_with(expected_cmd)
|
||||
|
||||
@patch('spoc.podman.run')
|
||||
def test_create_container_minimal(run):
|
||||
podman.create_container('someapp', 'someapp-cnt', 'example.com/someapp:0.23.6-210515')
|
||||
|
||||
expected_cmd = ['podman', 'container', 'create', '--name', 'someapp-cnt', '--pod', 'someapp',
|
||||
'--subuidname', 'spoc', '--subgidname', 'spoc',
|
||||
'--restart', 'unless-stopped', 'example.com/someapp:0.23.6-210515']
|
||||
run.assert_called_once_with(expected_cmd)
|
||||
|
||||
@patch('spoc.podman.run')
|
||||
def test_prune(run):
|
||||
podman.prune()
|
||||
|
||||
run.assert_has_calls([
|
||||
call(['podman', 'image', 'prune', '--all', '--force', '--volumes']),
|
||||
])
|
53
tests/test_repo.py
Normal file
53
tests/test_repo.py
Normal file
@ -0,0 +1,53 @@
|
||||
from unittest.mock import patch, call
|
||||
|
||||
import pytest
|
||||
|
||||
from spoc import config
|
||||
from spoc import repo
|
||||
|
||||
@patch('spoc.repo._DATA', {})
|
||||
@patch('requests.get')
|
||||
def test_load(req_get):
|
||||
repo.load()
|
||||
|
||||
req_get.assert_called_once_with(config.REPO_FILE_URL, auth=config.REGISTRY_AUTH, timeout=5)
|
||||
req_get.return_value.raise_for_status.assert_called_once()
|
||||
req_get.return_value.json.assert_called_once()
|
||||
|
||||
@patch('spoc.repo._DATA', {})
|
||||
@patch('requests.get')
|
||||
def test_load_twice_no_force(req_get):
|
||||
repo.load()
|
||||
repo.load()
|
||||
|
||||
req_get.assert_called_once_with(config.REPO_FILE_URL, auth=config.REGISTRY_AUTH, timeout=5)
|
||||
req_get.return_value.raise_for_status.assert_called_once()
|
||||
req_get.return_value.json.assert_called_once()
|
||||
|
||||
@patch('spoc.repo._DATA', {})
|
||||
@patch('requests.get')
|
||||
def test_load_twice_force(req_get):
|
||||
repo.load()
|
||||
repo.load(force=True)
|
||||
|
||||
expected_call = call(config.REPO_FILE_URL, auth=config.REGISTRY_AUTH, timeout=5)
|
||||
assert req_get.call_args_list.count(expected_call) == 2
|
||||
assert req_get.return_value.raise_for_status.call_count == 2
|
||||
assert req_get.return_value.json.call_count == 2
|
||||
|
||||
@patch('spoc.repo._DATA', {'someapp': {'version': '0.1'}})
|
||||
@patch('requests.get', side_effect=IOError())
|
||||
def test_load_empty_on_fail(req_get):
|
||||
with pytest.raises(IOError):
|
||||
repo.load(force=True)
|
||||
|
||||
req_get.assert_called_once_with(config.REPO_FILE_URL, auth=config.REGISTRY_AUTH, timeout=5)
|
||||
assert repo._DATA == {} # pylint: disable=protected-access
|
||||
|
||||
@patch('spoc.repo._DATA', {'someapp': {'version': '0.1'}})
|
||||
@patch('spoc.repo.load')
|
||||
def test_get_apps(repo_load):
|
||||
apps = repo.get_apps()
|
||||
|
||||
repo_load.assert_called_once()
|
||||
assert apps == {'someapp': {'version': '0.1'}}
|
267
tests/test_spoc.py
Normal file
267
tests/test_spoc.py
Normal file
@ -0,0 +1,267 @@
|
||||
from unittest.mock import call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import spoc
|
||||
|
||||
def test_apperror():
|
||||
exception = spoc.AppError('someapp')
|
||||
|
||||
assert exception.app_name == 'someapp'
|
||||
|
||||
def test_appalreadyinstallederror():
|
||||
exception = spoc.AppAlreadyInstalledError('someapp')
|
||||
|
||||
assert exception.app_name == 'someapp'
|
||||
assert isinstance(exception, spoc.AppError)
|
||||
|
||||
def test_appnotinstallederror():
|
||||
exception = spoc.AppNotInstalledError('someapp')
|
||||
|
||||
assert exception.app_name == 'someapp'
|
||||
assert isinstance(exception, spoc.AppError)
|
||||
|
||||
def test_appnotinrepoerror():
|
||||
exception = spoc.AppNotInRepoError('someapp')
|
||||
|
||||
assert exception.app_name == 'someapp'
|
||||
assert isinstance(exception, spoc.AppError)
|
||||
|
||||
def test_appnotupdateableerror():
|
||||
exception = spoc.AppNotUpdateableError('someapp')
|
||||
|
||||
assert exception.app_name == 'someapp'
|
||||
assert isinstance(exception, spoc.AppError)
|
||||
|
||||
@patch('spoc.podman.get_apps', return_value={'someapp': '0.2', 'anotherapp': '0.1'})
|
||||
def test_list_installed(get_apps):
|
||||
apps = spoc.list_installed()
|
||||
|
||||
get_apps.assert_called_once()
|
||||
|
||||
assert apps == {'anotherapp': '0.1', 'someapp': '0.2'}
|
||||
|
||||
@patch('spoc.repo.get_apps',
|
||||
return_value={'someapp': {'version': '0.2'}, 'anotherapp': {'version': '0.1'}})
|
||||
def test_list_online(get_apps):
|
||||
apps = spoc.list_online()
|
||||
|
||||
get_apps.assert_called_once()
|
||||
|
||||
assert apps == {'anotherapp': '0.1', 'someapp': '0.2'}
|
||||
|
||||
@patch('spoc.repo.get_apps',
|
||||
return_value={'someapp': {'version': '0.2'}, 'anotherapp': {'version': '0.1'}})
|
||||
@patch('spoc.podman.get_apps', return_value={'someapp': '0.1'})
|
||||
def test_list_updates(podman_get_apps, repo_get_apps):
|
||||
apps = spoc.list_updates()
|
||||
|
||||
repo_get_apps.assert_called_once()
|
||||
podman_get_apps.assert_called_once()
|
||||
|
||||
assert apps == {'someapp': '0.1 -> 0.2'}
|
||||
|
||||
@patch('spoc.repo.get_apps', return_value={'someapp': {'version': '0.1'}})
|
||||
@patch('spoc.podman.get_apps', return_value={})
|
||||
@patch('spoc.app.install')
|
||||
def test_install(app_install, podman_get_apps, repo_get_apps):
|
||||
spoc.install.__wrapped__('someapp')
|
||||
|
||||
podman_get_apps.assert_called_once()
|
||||
repo_get_apps.assert_called_once()
|
||||
app_install.assert_called_once_with('someapp', from_file=None)
|
||||
|
||||
@patch('spoc.repo.get_apps')
|
||||
@patch('spoc.podman.get_apps', return_value={})
|
||||
@patch('spoc.app.install')
|
||||
def test_install_from_file(app_install, podman_get_apps, repo_get_apps):
|
||||
spoc.install.__wrapped__('someapp', 'somefile')
|
||||
|
||||
podman_get_apps.assert_called_once()
|
||||
repo_get_apps.assert_not_called()
|
||||
app_install.assert_called_once_with('someapp', from_file='somefile')
|
||||
|
||||
@patch('spoc.repo.get_apps', return_value={'someapp': {'version': '0.1'}})
|
||||
@patch('spoc.podman.get_apps', return_value={'someapp': '0.1'})
|
||||
@patch('spoc.app.install')
|
||||
def test_install_already_installed(app_install, podman_get_apps, repo_get_apps):
|
||||
with pytest.raises(spoc.AppAlreadyInstalledError):
|
||||
spoc.install.__wrapped__('someapp')
|
||||
|
||||
podman_get_apps.assert_called_once()
|
||||
repo_get_apps.assert_not_called()
|
||||
app_install.assert_not_called()
|
||||
|
||||
@patch('spoc.repo.get_apps', return_value={})
|
||||
@patch('spoc.podman.get_apps', return_value={})
|
||||
@patch('spoc.app.install')
|
||||
def test_install_not_in_repo(app_install, podman_get_apps, repo_get_apps):
|
||||
with pytest.raises(spoc.AppNotInRepoError):
|
||||
spoc.install.__wrapped__('someapp')
|
||||
|
||||
podman_get_apps.assert_called_once()
|
||||
repo_get_apps.assert_called_once()
|
||||
app_install.assert_not_called()
|
||||
|
||||
@patch('spoc.list_updates', return_value={'someapp': '0.1 -> 0.2'})
|
||||
@patch('spoc.podman.get_apps', return_value={'someapp': '0.1'})
|
||||
@patch('spoc.app.update')
|
||||
def test_update(app_update, podman_get_apps, list_updates):
|
||||
spoc.update.__wrapped__('someapp')
|
||||
|
||||
podman_get_apps.assert_called_once()
|
||||
list_updates.assert_called_once()
|
||||
app_update.assert_called_once_with('someapp', from_file=None)
|
||||
|
||||
@patch('spoc.list_updates', return_value={})
|
||||
@patch('spoc.podman.get_apps', return_value={})
|
||||
@patch('spoc.app.update')
|
||||
def test_update_not_installed(app_update, podman_get_apps, list_updates):
|
||||
with pytest.raises(spoc.AppNotInstalledError):
|
||||
spoc.update.__wrapped__('someapp')
|
||||
|
||||
podman_get_apps.assert_called_once()
|
||||
list_updates.assert_not_called()
|
||||
app_update.assert_not_called()
|
||||
|
||||
@patch('spoc.list_updates', return_value={})
|
||||
@patch('spoc.podman.get_apps', return_value={'someapp': '0.1'})
|
||||
@patch('spoc.app.update')
|
||||
def test_update_not_updateable(app_update, podman_get_apps, list_updates):
|
||||
with pytest.raises(spoc.AppNotUpdateableError):
|
||||
spoc.update.__wrapped__('someapp')
|
||||
|
||||
podman_get_apps.assert_called_once()
|
||||
list_updates.assert_called_once()
|
||||
app_update.assert_not_called()
|
||||
|
||||
@patch('spoc.podman.get_apps', return_value={'someapp': '0.1'})
|
||||
@patch('spoc.app.uninstall')
|
||||
def test_uninstall(app_uninstall, podman_get_apps):
|
||||
spoc.uninstall.__wrapped__('someapp')
|
||||
|
||||
podman_get_apps.assert_called_once()
|
||||
app_uninstall.assert_called_once_with('someapp')
|
||||
|
||||
@patch('spoc.podman.get_apps', return_value={})
|
||||
@patch('spoc.app.uninstall')
|
||||
def test_uninstall_not_installed(app_uninstall, podman_get_apps):
|
||||
with pytest.raises(spoc.AppNotInstalledError):
|
||||
spoc.uninstall.__wrapped__('someapp')
|
||||
|
||||
podman_get_apps.assert_called_once()
|
||||
app_uninstall.assert_not_called()
|
||||
|
||||
@patch('spoc.podman.get_apps', return_value={'someapp': '0.1'})
|
||||
@patch('spoc.podman.start_pod')
|
||||
def test_start(start_pod, podman_get_apps):
|
||||
spoc.start.__wrapped__('someapp')
|
||||
|
||||
podman_get_apps.assert_called_once()
|
||||
start_pod.assert_called_once_with('someapp')
|
||||
|
||||
@patch('spoc.podman.get_apps', return_value={})
|
||||
@patch('spoc.podman.start_pod')
|
||||
def test_start_not_installed(start_pod, podman_get_apps):
|
||||
with pytest.raises(spoc.AppNotInstalledError):
|
||||
spoc.start.__wrapped__('someapp')
|
||||
|
||||
podman_get_apps.assert_called_once()
|
||||
start_pod.assert_not_called()
|
||||
|
||||
@patch('spoc.podman.get_apps', return_value={'someapp': '0.1'})
|
||||
@patch('spoc.podman.stop_pod')
|
||||
def test_stop(stop_pod, podman_get_apps):
|
||||
spoc.stop.__wrapped__('someapp')
|
||||
|
||||
podman_get_apps.assert_called_once()
|
||||
stop_pod.assert_called_once_with('someapp')
|
||||
|
||||
@patch('spoc.podman.get_apps', return_value={})
|
||||
@patch('spoc.podman.stop_pod')
|
||||
def test_stop_not_installed(stop_pod, podman_get_apps):
|
||||
with pytest.raises(spoc.AppNotInstalledError):
|
||||
spoc.stop.__wrapped__('someapp')
|
||||
|
||||
podman_get_apps.assert_called_once()
|
||||
stop_pod.assert_not_called()
|
||||
|
||||
@patch('spoc.podman.get_apps', return_value={'someapp': '0.1'})
|
||||
@patch('spoc.podman.get_pod_status', return_value='RESULT')
|
||||
def test_status(get_pod_status, podman_get_apps):
|
||||
status = spoc.status('someapp')
|
||||
|
||||
podman_get_apps.assert_called_once()
|
||||
get_pod_status.assert_called_once_with('someapp')
|
||||
assert status == 'RESULT'
|
||||
|
||||
@patch('spoc.podman.get_apps')
|
||||
@patch('spoc.podman.get_pod_status', return_value='RESULT')
|
||||
def test_status_all(get_pod_status, podman_get_apps):
|
||||
status = spoc.status()
|
||||
|
||||
podman_get_apps.assert_not_called()
|
||||
get_pod_status.assert_called_once_with(None)
|
||||
assert status == 'RESULT'
|
||||
|
||||
@patch('spoc.podman.get_apps', return_value={})
|
||||
@patch('spoc.podman.get_pod_status')
|
||||
def test_status_not_installed(get_pod_status, podman_get_apps):
|
||||
with pytest.raises(spoc.AppNotInstalledError):
|
||||
spoc.status('someapp')
|
||||
|
||||
podman_get_apps.assert_called_once()
|
||||
get_pod_status.assert_not_called()
|
||||
|
||||
@patch('spoc.podman.get_apps', return_value={'someapp': '0.1'})
|
||||
@patch('spoc.autostart.set_app')
|
||||
def test_set_autostart(set_app, podman_get_apps):
|
||||
spoc.set_autostart.__wrapped__('someapp', True)
|
||||
|
||||
podman_get_apps.assert_called_once()
|
||||
set_app.assert_called_once_with('someapp', True)
|
||||
|
||||
@patch('spoc.podman.get_apps', return_value={})
|
||||
@patch('spoc.autostart.set_app')
|
||||
def test_set_autostart_not_installed(set_app, podman_get_apps):
|
||||
with pytest.raises(spoc.AppNotInstalledError):
|
||||
spoc.set_autostart.__wrapped__('someapp', True)
|
||||
|
||||
podman_get_apps.assert_called_once()
|
||||
set_app.assert_not_called()
|
||||
|
||||
@patch('spoc.autostart.get_apps', return_value={'someapp', 'anotherapp'})
|
||||
@patch('spoc.podman.start_pod')
|
||||
def test_start_autostarted(start_pod, get_apps):
|
||||
spoc.start_autostarted.__wrapped__()
|
||||
|
||||
get_apps.assert_called_once()
|
||||
start_pod.assert_has_calls([
|
||||
call('someapp'),
|
||||
call('anotherapp'),
|
||||
], any_order=True)
|
||||
|
||||
@patch('spoc.podman.get_apps', return_value={'someapp': '0.1', 'anotherapp': '0.1'})
|
||||
@patch('spoc.podman.stop_pod')
|
||||
def test_stop_all(stop_pod, get_apps):
|
||||
spoc.stop_all.__wrapped__()
|
||||
|
||||
get_apps.assert_called_once()
|
||||
stop_pod.assert_has_calls([
|
||||
call('someapp'),
|
||||
call('anotherapp'),
|
||||
], any_order=True)
|
||||
|
||||
@patch('spoc.config.write_auth')
|
||||
@patch('spoc.repo.load')
|
||||
def test_login(repo_load, write_auth):
|
||||
spoc.login.__wrapped__('somehost', 'someuser', 'somepass')
|
||||
|
||||
write_auth.assert_called_once_with('somehost', 'someuser', 'somepass')
|
||||
repo_load.assert_called_once_with(force=True)
|
||||
|
||||
@patch('spoc.podman.prune')
|
||||
def test_prune(prune):
|
||||
spoc.prune.__wrapped__()
|
||||
|
||||
prune.assert_called_once()
|
199
usr/bin/spoc-app
199
usr/bin/spoc-app
@ -1,199 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pkg_resources import parse_version
|
||||
|
||||
from spoc import repo_local, repo_online, repo_publish
|
||||
from spoc.app import App
|
||||
from spoc.cli import ActionQueue, print_lock, readable_size
|
||||
from spoc.config import LOCK_FILE
|
||||
from spoc.flock import locked
|
||||
from spoc.image import Image
|
||||
|
||||
def listing(list_type):
|
||||
# Lists applications in particular state
|
||||
if list_type == 'installed':
|
||||
apps = repo_local.get_apps()
|
||||
elif list_type == 'online':
|
||||
apps = repo_online.get_apps()
|
||||
elif list_type == 'updates':
|
||||
online_apps = repo_online.get_apps()
|
||||
apps = [a for a,d in repo_local.get_apps().items() if a in online_apps and parse_version(online_apps[a]['version']) > parse_version(d['version'])]
|
||||
elif list_type == 'published':
|
||||
apps = repo_publish.get_apps()
|
||||
elif list_type == 'running':
|
||||
apps = [app for app in repo_local.get_apps() if App(app).is_running()]
|
||||
elif list_type == 'stopped':
|
||||
apps = [app for app in repo_local.get_apps() if App(app).is_stopped()]
|
||||
for app in apps:
|
||||
print(app)
|
||||
|
||||
@locked(LOCK_FILE, print_lock)
|
||||
def install(app_name):
|
||||
# Install application from online repository
|
||||
queue = ActionQueue()
|
||||
required_images = []
|
||||
for container in repo_online.get_app(app_name)['containers'].values():
|
||||
required_images.extend(repo_online.get_image(container['image'])['layers'])
|
||||
local_images = repo_local.get_images()
|
||||
# Layers need to be downloaded in correct order
|
||||
for layer in list(dict.fromkeys(required_images)):
|
||||
if layer not in local_images:
|
||||
queue.download_image(Image(layer, False))
|
||||
queue.install_app(App(app_name, False, False))
|
||||
queue.process()
|
||||
|
||||
@locked(LOCK_FILE, print_lock)
|
||||
def update(app_name):
|
||||
# Update application from online repository
|
||||
queue = ActionQueue()
|
||||
required_images = []
|
||||
for container in repo_online.get_app(app_name)['containers'].values():
|
||||
required_images.extend(repo_online.get_image(container['image'])['layers'])
|
||||
local_images = repo_local.get_images()
|
||||
# Layers need to be downloaded in correct order
|
||||
for layer in list(dict.fromkeys(required_images)):
|
||||
if layer not in local_images:
|
||||
queue.download_image(Image(layer, False))
|
||||
queue.update_app(App(app_name, False))
|
||||
queue.process()
|
||||
|
||||
@locked(LOCK_FILE, print_lock)
|
||||
def uninstall(app_name):
|
||||
# Remove application and its containers from local repository
|
||||
queue = ActionQueue()
|
||||
queue.uninstall_app(App(app_name, False))
|
||||
queue.process()
|
||||
|
||||
def start(app_name):
|
||||
# Start all application containers
|
||||
queue = ActionQueue()
|
||||
queue.start_app(App(app_name))
|
||||
queue.process()
|
||||
|
||||
def stop(app_name):
|
||||
# Stop all application containers
|
||||
queue = ActionQueue()
|
||||
queue.stop_app(App(app_name))
|
||||
queue.process()
|
||||
|
||||
def status(app_name):
|
||||
# Print status of all application containers
|
||||
for container,status in sorted(App(app_name).status().items()):
|
||||
print(f'{container}: {status.value}')
|
||||
|
||||
def publish(filename, force):
|
||||
app_name = os.path.basename(os.path.dirname(os.path.abspath(filename)))
|
||||
# Check if publishing is needed and attempt to publish the application
|
||||
if force or app_name not in repo_publish.get_apps():
|
||||
app = App(app_name, False, False)
|
||||
print(f'Publishing application {app_name} from file {os.path.abspath(filename)}')
|
||||
app.unpublish()
|
||||
size, dlsize = app.publish(filename)
|
||||
print(f'Application {app_name} compressed from {readable_size(size)} to {readable_size(dlsize)} and published successfully')
|
||||
else:
|
||||
print(f'Application {app_name} already published, skipping publish task')
|
||||
|
||||
def unpublish(app_name):
|
||||
# Remove the application from publish repo
|
||||
App(app_name, False, False).unpublish()
|
||||
|
||||
def autostart(app_name, value):
|
||||
# Set if the application should be autostarted on boot
|
||||
value = value.lower() in ('1', 'on', 'enable', 'true')
|
||||
App(app_name, False).set_autostart(value)
|
||||
|
||||
def start_autostarted():
|
||||
# Start all applications (resp. their containers) which are set to be autoostarted on boot
|
||||
apps = [App(a) for a,d in repo_local.get_apps().items() if d.get('autostart')]
|
||||
for app in apps:
|
||||
app.start()
|
||||
|
||||
def stop_all():
|
||||
# Stop all applications (resp. their containers)
|
||||
apps = [App(a) for a,d in repo_local.get_apps().items()]
|
||||
for app in apps:
|
||||
app.stop()
|
||||
|
||||
parser = argparse.ArgumentParser(description='SPOC application manager')
|
||||
parser.set_defaults(action=None)
|
||||
subparsers = parser.add_subparsers()
|
||||
|
||||
parser_list = subparsers.add_parser('list')
|
||||
parser_list.set_defaults(action=listing)
|
||||
parser_list.add_argument('type', choices=('installed', 'online', 'updates', 'published', 'running', 'stopped'), default='installed', const='installed', nargs='?', help='Selected repository or application criteria')
|
||||
|
||||
parser_install = subparsers.add_parser('install')
|
||||
parser_install.set_defaults(action=install)
|
||||
parser_install.add_argument('app', help='Name of the application to install')
|
||||
|
||||
parser_update = subparsers.add_parser('update')
|
||||
parser_update.set_defaults(action=update)
|
||||
parser_update.add_argument('app', help='Name of the application to update')
|
||||
|
||||
parser_uninstall = subparsers.add_parser('uninstall')
|
||||
parser_uninstall.set_defaults(action=uninstall)
|
||||
parser_uninstall.add_argument('app', help='Name of the application to uninstall')
|
||||
|
||||
parser_start = subparsers.add_parser('start')
|
||||
parser_start.set_defaults(action=start)
|
||||
parser_start.add_argument('app', help='Name of the application to start')
|
||||
|
||||
parser_stop = subparsers.add_parser('stop')
|
||||
parser_stop.set_defaults(action=stop)
|
||||
parser_stop.add_argument('app', help='Name of the application to stop')
|
||||
|
||||
parser_status = subparsers.add_parser('status')
|
||||
parser_status.set_defaults(action=status)
|
||||
parser_status.add_argument('app', help='Name of the application to check')
|
||||
|
||||
parser_publish = subparsers.add_parser('publish')
|
||||
parser_publish.set_defaults(action=publish)
|
||||
parser_publish.add_argument('-f', '--force', action='store_true', help='Force republish already published application')
|
||||
parser_publish.add_argument('filename', help='Path to metadata file of the application to publish')
|
||||
|
||||
parser_unpublish = subparsers.add_parser('unpublish')
|
||||
parser_unpublish.set_defaults(action=unpublish)
|
||||
parser_unpublish.add_argument('app', help='Name of the application to unpublish')
|
||||
|
||||
parser_autostart = subparsers.add_parser('autostart')
|
||||
parser_autostart.set_defaults(action=autostart)
|
||||
parser_autostart.add_argument('app', help='Name of the application to be automatically started')
|
||||
parser_autostart.add_argument('value', choices=('1', 'on', 'enable', 'true', '0', 'off', 'disable', 'false'), help='Set or unset the applications to be automatically started after the host boots up')
|
||||
|
||||
parser_start_autostarted = subparsers.add_parser('start-autostarted')
|
||||
parser_start_autostarted.set_defaults(action=start_autostarted)
|
||||
|
||||
parser_stop_all = subparsers.add_parser('stop-all')
|
||||
parser_stop_all.set_defaults(action=stop_all)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.action is listing:
|
||||
listing(args.type)
|
||||
elif args.action is install:
|
||||
install(args.app)
|
||||
elif args.action is update:
|
||||
update(args.app)
|
||||
elif args.action is uninstall:
|
||||
uninstall(args.app)
|
||||
elif args.action is start:
|
||||
start(args.app)
|
||||
elif args.action is stop:
|
||||
stop(args.app)
|
||||
elif args.action is status:
|
||||
status(args.app)
|
||||
elif args.action is publish:
|
||||
publish(args.filename, args.force)
|
||||
elif args.action is unpublish:
|
||||
unpublish(args.app)
|
||||
elif args.action is autostart:
|
||||
autostart(args.app, args.value)
|
||||
elif args.action is start_autostarted:
|
||||
start_autostarted()
|
||||
elif args.action is stop_all:
|
||||
stop_all()
|
||||
else:
|
||||
parser.print_usage()
|
@ -1,189 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
from spoc import repo_local
|
||||
from spoc.config import VOLUMES_DIR
|
||||
from spoc.container import Container
|
||||
from spoc.image import Image
|
||||
|
||||
def listing(state):
|
||||
# Lits containers in particular state
|
||||
if state == 'all':
|
||||
containers = repo_local.get_containers().keys()
|
||||
elif state == 'running':
|
||||
containers = [c for c in repo_local.get_containers() if Container(c).is_running()]
|
||||
elif state == 'stopped':
|
||||
containers = [c for c in repo_local.get_containers() if Container(c).is_stopped()]
|
||||
for container in containers:
|
||||
print(container)
|
||||
|
||||
def modify_depend(container, depend):
|
||||
# Change container dependencies
|
||||
if depend.startswith('!'):
|
||||
try:
|
||||
container.depends.remove(depend[1:])
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
# Add the dependency and remove duplicates
|
||||
container.depends.append(depend)
|
||||
container.depends = list(set(container.depends))
|
||||
|
||||
def modify_mount(container, mount):
|
||||
# Change container mount points
|
||||
volume,mountpoint = mount.split(':', 1)
|
||||
if mountpoint:
|
||||
container.mounts[volume] = mountpoint
|
||||
else:
|
||||
try:
|
||||
del container.mounts[volume]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def modify_env(container, env):
|
||||
# Change container environment values
|
||||
key,value = env.split('=', 1)
|
||||
if value:
|
||||
container.env[key] = value
|
||||
else:
|
||||
try:
|
||||
del container.env[key]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def modify_container(container, depends, mounts, envs, uid, gid, cmd, cwd, ready, halt):
|
||||
# Change container definition
|
||||
for depend in depends:
|
||||
modify_depend(container, depend)
|
||||
for mount in mounts:
|
||||
modify_mount(container, mount)
|
||||
for env in envs:
|
||||
modify_env(container, env)
|
||||
args = locals()
|
||||
for member in ('uid', 'gid', 'cmd', 'cwd', 'ready', 'halt'):
|
||||
value = args[member]
|
||||
if value:
|
||||
setattr(container, member, value)
|
||||
|
||||
def create(container_name, image_name, depends, mounts, env, uid, gid, cmd, cwd, ready, halt):
|
||||
# Create container based on image definition and extra fields
|
||||
container = Container(container_name, False)
|
||||
container.set_definition(Image(image_name).get_definition())
|
||||
modify_container(container, depends, mounts, env, uid, gid, cmd, cwd, ready, halt)
|
||||
container.create()
|
||||
|
||||
def modify(container_name, depends, mounts, env, uid, gid, cmd, cwd, ready, halt):
|
||||
# Change configuration of an existing container
|
||||
container = Container(container_name)
|
||||
modify_container(container, depends, mounts, env, uid, gid, cmd, cwd, ready, halt)
|
||||
container.create()
|
||||
|
||||
def destroy(container_name):
|
||||
# Remove container and its directory
|
||||
container = Container(container_name, False)
|
||||
if container.is_running():
|
||||
container.stop()
|
||||
container.destroy()
|
||||
|
||||
def start(container_name, command):
|
||||
# Start the container using init values from its definition
|
||||
Container(container_name).start(command)
|
||||
|
||||
def stop(container_name):
|
||||
# Stop the container using halt signal from its definition
|
||||
Container(container_name).stop()
|
||||
|
||||
def status(container_name):
|
||||
# Prints current running status of the container
|
||||
print(Container(container_name).get_state().value)
|
||||
|
||||
def execute(container_name, command, uid, gid):
|
||||
# Execute a command in container's namespace
|
||||
result = Container(container_name).execute(command, uid, gid)
|
||||
# Set returncode to that of the command
|
||||
sys.exit(result.returncode)
|
||||
|
||||
parser = argparse.ArgumentParser(description='SPOC container manager')
|
||||
parser.set_defaults(action=None)
|
||||
subparsers = parser.add_subparsers()
|
||||
|
||||
parser_list = subparsers.add_parser('list')
|
||||
parser_list.set_defaults(action=listing)
|
||||
parser_list.add_argument('type', choices=('all', 'running', 'stopped'), default='all', const='all', nargs='?', help='Selected container criteria')
|
||||
|
||||
parser_create = subparsers.add_parser('create')
|
||||
parser_create.set_defaults(action=create)
|
||||
parser_create.add_argument('-d', '--depends', action='append', default=[], help='Add another container as a start dependency')
|
||||
parser_create.add_argument('-m', '--mount', action='append', default=[], help='Add mount to the container - format volume:mountpoint[:file]')
|
||||
parser_create.add_argument('-e', '--env', action='append', default=[], help='Add environment variable for the container - format KEY=value')
|
||||
parser_create.add_argument('-u', '--uid', help='Sets the container init UID')
|
||||
parser_create.add_argument('-g', '--gid', help='Sets the container init GID')
|
||||
parser_create.add_argument('-c', '--cmd', help='Sets the container init command')
|
||||
parser_create.add_argument('-w', '--workdir', help='Sets the container init working directory')
|
||||
parser_create.add_argument('-r', '--ready', help='Sets the container ready command')
|
||||
parser_create.add_argument('-s', '--stopsig', help='Sets the signal to be sent to init on container shutdown')
|
||||
parser_create.add_argument('container', help='Name of the container to create')
|
||||
parser_create.add_argument('image', help='Name of the image of which the container should be based')
|
||||
|
||||
parser_modify = subparsers.add_parser('modify')
|
||||
parser_modify.set_defaults(action=modify)
|
||||
parser_modify.add_argument('-d', '--depends', action='append', default=[], help='Add another container as a start dependency - prepend the name with ! to remove the dependency')
|
||||
parser_modify.add_argument('-m', '--mount', action='append', default=[], help='Add mount to the container - format volume:mountpoint - specify empty mountpoint to remove the mount')
|
||||
parser_modify.add_argument('-e', '--env', action='append', default=[], help='Add environment variable for the container - format KEY=value - specify empty value to remove the env')
|
||||
parser_modify.add_argument('-u', '--uid', help='Sets the container init UID')
|
||||
parser_modify.add_argument('-g', '--gid', help='Sets the container init GID')
|
||||
parser_modify.add_argument('-c', '--cmd', help='Sets the container init command')
|
||||
parser_modify.add_argument('-w', '--workdir', help='Sets the container init working directory')
|
||||
parser_modify.add_argument('-r', '--ready', help='Sets the container ready command')
|
||||
parser_modify.add_argument('-s', '--stopsig', help='Sets the signal to be sent to init on container shutdown')
|
||||
parser_modify.add_argument('container', help='Name of the container to modify')
|
||||
|
||||
parser_destroy = subparsers.add_parser('destroy')
|
||||
parser_destroy.set_defaults(action=destroy)
|
||||
parser_destroy.add_argument('container', help='Name of the container to destroy')
|
||||
|
||||
parser_start = subparsers.add_parser('start')
|
||||
parser_start.set_defaults(action=start)
|
||||
parser_start.add_argument('container', help='Name of the container to start')
|
||||
parser_start.add_argument('command', nargs=argparse.REMAINDER, help='Command to be run instead of the default init command')
|
||||
|
||||
parser_stop = subparsers.add_parser('stop')
|
||||
parser_stop.set_defaults(action=stop)
|
||||
parser_stop.add_argument('container', help='Name of the container to stop')
|
||||
|
||||
parser_status = subparsers.add_parser('status')
|
||||
parser_status.set_defaults(action=status)
|
||||
parser_status.add_argument('container', help='Name of the container to check')
|
||||
|
||||
parser_exec = subparsers.add_parser('exec')
|
||||
parser_exec.set_defaults(action=execute)
|
||||
parser_exec.add_argument('-u', '--uid', help='Sets the command UID')
|
||||
parser_exec.add_argument('-g', '--gid', help='Sets the command GID')
|
||||
parser_exec.add_argument('container', help='Name of the container in which to run the command')
|
||||
parser_exec.add_argument('command', nargs=argparse.REMAINDER, help='The command to be run')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.action is listing:
|
||||
listing(args.type)
|
||||
elif args.action is create:
|
||||
create(args.container, args.image, args.depends, args.mount, args.env, args.uid, args.gid, args.cmd, args.workdir, args.ready, args.stopsig)
|
||||
elif args.action is modify:
|
||||
modify(args.container, args.depends, args.mount, args.env, args.uid, args.gid, args.cmd, args.workdir, args.ready, args.stopsig)
|
||||
elif args.action is destroy:
|
||||
destroy(args.container)
|
||||
elif args.action is start:
|
||||
start(args.container, args.command)
|
||||
elif args.action is stop:
|
||||
stop(args.container)
|
||||
elif args.action is status:
|
||||
status(args.container)
|
||||
elif args.action is execute:
|
||||
execute(args.container, args.command, args.uid, args.gid)
|
||||
else:
|
||||
parser.print_usage()
|
@ -1,16 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
|
||||
from spoc.container import Container
|
||||
|
||||
if __name__ == '__main__':
|
||||
hook_type = os.environ['LXC_HOOK_TYPE']
|
||||
container = Container(os.environ['LXC_NAME'])
|
||||
if hook_type == 'pre-start':
|
||||
container.clean_ephemeral_layer()
|
||||
container.mount_rootfs()
|
||||
elif hook_type == 'post-stop':
|
||||
container.unmount_rootfs()
|
||||
container.clean_ephemeral_layer()
|
@ -1,161 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
from spoc import repo_local, repo_online, repo_publish
|
||||
from spoc.cli import ActionQueue, print_lock, readable_size
|
||||
from spoc.config import LOCK_FILE
|
||||
from spoc.depsolver import DepSolver
|
||||
from spoc.exceptions import ImageNotFoundError
|
||||
from spoc.flock import locked
|
||||
from spoc.image import Image
|
||||
from spoc.imagebuilder import ImageBuilder
|
||||
|
||||
def get_image_name(file_path):
|
||||
# Read and return image name from image file
|
||||
with open(file_path) as f:
|
||||
for line in f:
|
||||
if line.startswith('IMAGE '):
|
||||
return line.split()[1]
|
||||
return None
|
||||
|
||||
def listing(list_type):
|
||||
# Lists images in particular state
|
||||
if list_type == 'installed':
|
||||
images = repo_local.get_images()
|
||||
elif list_type == 'online':
|
||||
images = repo_online.get_images()
|
||||
elif list_type == 'published':
|
||||
images = repo_publish.get_images()
|
||||
for image in images:
|
||||
print(image)
|
||||
|
||||
@locked(LOCK_FILE, print_lock)
|
||||
def download(image_name):
|
||||
# Download and unpack image from online repository
|
||||
queue = ActionQueue()
|
||||
local_images = repo_local.get_images()
|
||||
for layer in repo_online.get_image(image_name)['layers']:
|
||||
if layer not in local_images:
|
||||
queue.download_image(Image(layer, False))
|
||||
queue.process()
|
||||
|
||||
@locked(LOCK_FILE, print_lock)
|
||||
def delete(image_name):
|
||||
# Remove the image including all images that have it as one of its parents
|
||||
# Check if image is in use
|
||||
used_by = [c for c,d in repo_local.get_containers().items() if image_name in d['layers']]
|
||||
if used_by:
|
||||
sys.exit(f'Error: Image {image_name} is used by container{"s" if len(used_by) > 1 else ""} {", ".join(used_by)}')
|
||||
# Gather layers inheriting from the layer to be removed which should be removed as well
|
||||
retained_layers = set(image for image,definition in repo_local.get_images().items() if image_name not in definition['layers'])
|
||||
remove_layers(retained_layers)
|
||||
|
||||
@locked(LOCK_FILE, print_lock)
|
||||
def clean():
|
||||
# Remove images which aren't used in any locally defined containers
|
||||
retained_layers = set()
|
||||
for definition in repo_local.get_containers().values():
|
||||
retained_layers.update(definition['layers'])
|
||||
remove_layers(retained_layers)
|
||||
|
||||
def remove_layers(retained_layers):
|
||||
# Enqueue removal of images for cleanup
|
||||
depsolver = DepSolver()
|
||||
# Build dependency tree to safely remove the images in order of dependency
|
||||
for image in set(repo_local.get_images()) - retained_layers:
|
||||
image = Image(image)
|
||||
depsolver.add(image.name, set(image.layers) - retained_layers, image)
|
||||
# Enqueue and run the removal actions
|
||||
queue = ActionQueue()
|
||||
for image in reversed(depsolver.solve()):
|
||||
queue.delete_image(image)
|
||||
queue.process()
|
||||
|
||||
@locked(LOCK_FILE, print_lock)
|
||||
def build(filename, force, do_publish):
|
||||
# Check if a build is needed and attempt to build the image from image file
|
||||
image_name = get_image_name(filename)
|
||||
if force or image_name not in repo_local.get_images():
|
||||
image = Image(image_name, False)
|
||||
print(f'Building image {image_name} from file {os.path.abspath(filename)}')
|
||||
image.delete()
|
||||
image.create(ImageBuilder(), filename)
|
||||
print(f'Image {image_name} built successfully')
|
||||
# If publishing was requested, force publish after successful build
|
||||
force = True
|
||||
else:
|
||||
print(f'Image {image_name} already built, skipping build task')
|
||||
if do_publish:
|
||||
publish(image_name, force)
|
||||
|
||||
def publish(image_name, force):
|
||||
# Check if publishing is needed and attempt to publish the image
|
||||
if force or image_name not in repo_publish.get_images():
|
||||
image = Image(image_name)
|
||||
print(f'Publishing image {image_name}')
|
||||
image.unpublish()
|
||||
size, dlsize = image.publish()
|
||||
print(f'Image {image_name} compressed from {readable_size(size)} to {readable_size(dlsize)} and published successfully')
|
||||
else:
|
||||
print(f'Image {image_name} already published, skipping publish task')
|
||||
|
||||
def unpublish(image_name):
|
||||
# Remove the image from publish repo
|
||||
Image(image_name, False).unpublish()
|
||||
|
||||
parser = argparse.ArgumentParser(description='SPOC image manager')
|
||||
parser.set_defaults(action=None)
|
||||
subparsers = parser.add_subparsers()
|
||||
|
||||
parser_list = subparsers.add_parser('list')
|
||||
parser_list.set_defaults(action=listing)
|
||||
parser_list.add_argument('type', choices=('installed', 'online', 'published'), default='installed', const='installed', nargs='?', help='Selected repository')
|
||||
|
||||
parser_download = subparsers.add_parser('download')
|
||||
parser_download.set_defaults(action=download)
|
||||
parser_download.add_argument('image', help='Name of the image to download')
|
||||
|
||||
parser_delete = subparsers.add_parser('delete')
|
||||
parser_delete.set_defaults(action=delete)
|
||||
parser_delete.add_argument('image', help='Name of the image to delete')
|
||||
|
||||
parser_clean = subparsers.add_parser('clean')
|
||||
parser_clean.set_defaults(action=clean)
|
||||
|
||||
parser_build = subparsers.add_parser('build')
|
||||
parser_build.set_defaults(action=build)
|
||||
parser_build.add_argument('-f', '--force', action='store_true', help='Force rebuild already existing image')
|
||||
parser_build.add_argument('-p', '--publish', action='store_true', help='Publish the image after successful build')
|
||||
parser_build.add_argument('filename', help='Path to the file with build recipe')
|
||||
|
||||
parser_publish = subparsers.add_parser('publish')
|
||||
parser_publish.set_defaults(action=publish)
|
||||
parser_publish.add_argument('-f', '--force', action='store_true', help='Force republish already published image')
|
||||
parser_publish.add_argument('image', help='Name of the image to publish')
|
||||
|
||||
parser_unpublish = subparsers.add_parser('unpublish')
|
||||
parser_unpublish.set_defaults(action=unpublish)
|
||||
parser_unpublish.add_argument('image', help='Name of the image to unpublish')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.action is listing:
|
||||
listing(args.type)
|
||||
elif args.action is download:
|
||||
download(args.image)
|
||||
elif args.action is delete:
|
||||
delete(args.image)
|
||||
elif args.action is clean:
|
||||
clean()
|
||||
elif args.action is build:
|
||||
build(args.filename, args.force, args.publish)
|
||||
elif args.action is publish:
|
||||
publish(args.image, args.force)
|
||||
elif args.action is unpublish:
|
||||
unpublish(args.image)
|
||||
else:
|
||||
parser.print_usage()
|
@ -1 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
@ -1,213 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import urllib.parse
|
||||
|
||||
from . import config, repo_local, repo_online, repo_publish
|
||||
from .container import Container
|
||||
from .image import Image
|
||||
|
||||
DEFINITION_MEMBERS = {'version', 'meta', 'autostart', 'containers'}
|
||||
|
||||
class App:
|
||||
def __init__(self, name, define_containers=True, load_from_repo=True):
|
||||
self.name = name
|
||||
self.version = None
|
||||
self.app_dir = os.path.join(config.APPS_DIR, name)
|
||||
self.meta = {}
|
||||
self.autostart = False
|
||||
self.containers = []
|
||||
if load_from_repo:
|
||||
self.set_definition(repo_local.get_app(name), define_containers)
|
||||
|
||||
def set_definition(self, definition, define_containers):
|
||||
# Set attributes given by definition
|
||||
for key in DEFINITION_MEMBERS.intersection(definition):
|
||||
setattr(self, key, definition[key])
|
||||
# Populate containers property with actual container objects
|
||||
self.containers = [Container(container, define_containers) for container in definition['containers']]
|
||||
|
||||
def get_definition(self):
|
||||
# Return shallow copy of image definition as dictionary
|
||||
definition = {}
|
||||
for key in DEFINITION_MEMBERS:
|
||||
value = getattr(self, key)
|
||||
if value:
|
||||
definition[key] = copy.copy(value)
|
||||
# Overwrite containers key with list of container names
|
||||
definition['containers'] = [container.name for container in self.containers]
|
||||
return definition
|
||||
|
||||
def download(self, observer=None):
|
||||
# Download the archive with application scripts and install data
|
||||
os.makedirs(config.TMP_APPS_DIR, 0o700, True)
|
||||
archive_url = urllib.parse.urljoin(config.ONLINE_APPS_URL, f'{self.name}.tar.xz')
|
||||
archive_path = os.path.join(config.TMP_APPS_DIR, f'{self.name}.tar.xz')
|
||||
definition = repo_online.get_app(self.name)
|
||||
if observer:
|
||||
observer.units_total = definition['dlsize']
|
||||
repo_online.download_archive(archive_url, archive_path, definition['hash'], observer)
|
||||
|
||||
def unpack_downloaded(self, observer=None):
|
||||
# Unpack downloaded archive with application scripts and install data
|
||||
archive_path = os.path.join(config.TMP_APPS_DIR, f'{self.name}.tar.xz')
|
||||
definition = repo_online.get_app(self.name)
|
||||
if observer:
|
||||
observer.units_total = definition['size']
|
||||
repo_online.unpack_archive(archive_path, config.APPS_DIR, definition['hash'], observer)
|
||||
|
||||
def run_script(self, action):
|
||||
# Runs script for an app, if the script is present
|
||||
script_dir = os.path.join(self.app_dir, action)
|
||||
script_path = os.path.join(self.app_dir, f'{script_dir}.sh')
|
||||
if os.path.exists(script_path):
|
||||
# Run the script in its working directory, if there is one, so it doesn't have to figure out paths to packaged files
|
||||
env = os.environ.copy()
|
||||
env['LAYERS_DIR'] = config.LAYERS_DIR
|
||||
env['VOLUMES_DIR'] = config.VOLUMES_DIR
|
||||
env['APPS_DIR'] = config.APPS_DIR
|
||||
env['LOG_DIR'] = config.LOG_DIR
|
||||
cwd = script_dir if os.path.exists(script_dir) else self.app_dir
|
||||
subprocess.run(script_path, cwd=cwd, env=env, check=True)
|
||||
|
||||
def create_container(self, name, definition):
|
||||
# Create container and enhance its definition (typically mounts) based on application requirements
|
||||
container = Container(name, False)
|
||||
container.set_definition(Image(definition['image']).get_definition())
|
||||
if 'depends' in definition:
|
||||
container.depends = definition['depends']
|
||||
if 'env' in definition:
|
||||
container.env.update(definition['env'])
|
||||
if 'mounts' in definition:
|
||||
container.mounts.update(definition['mounts'])
|
||||
container.create()
|
||||
self.containers.append(container)
|
||||
|
||||
def install(self, observer=None):
|
||||
# Install the application
|
||||
definition = repo_online.get_app(self.name)
|
||||
self.version = definition['version']
|
||||
self.meta = definition['meta']
|
||||
self.run_script('uninstall')
|
||||
# Build containers
|
||||
for container,container_defintion in definition['containers'].items():
|
||||
self.create_container(container, container_defintion)
|
||||
# Run install script and register the app
|
||||
try:
|
||||
self.run_script('install')
|
||||
except:
|
||||
# Stop all containers if install.sh fails
|
||||
for container in self.containers:
|
||||
container.stop()
|
||||
raise
|
||||
repo_local.register_app(self.name, self.get_definition())
|
||||
|
||||
def update(self, observer=None):
|
||||
# Stop and remove containers
|
||||
for container in self.containers.copy():
|
||||
if container.is_running():
|
||||
container.stop()
|
||||
container.destroy()
|
||||
self.containers.remove(container)
|
||||
# Load online definition
|
||||
definition = repo_online.get_app(self.name)
|
||||
self.version = definition['version']
|
||||
self.meta = definition['meta']
|
||||
# Build containers
|
||||
for container,container_defintion in definition['containers'].items():
|
||||
self.create_container(container, container_defintion)
|
||||
# Run update script and re-register the app
|
||||
try:
|
||||
self.run_script('update')
|
||||
except:
|
||||
# Stop all containers if update.sh fails
|
||||
for container in self.containers:
|
||||
container.stop()
|
||||
raise
|
||||
repo_local.register_app(self.name, self.get_definition())
|
||||
|
||||
def uninstall(self, observer=None):
|
||||
# Stop and remove containers
|
||||
for container in self.containers:
|
||||
if container.is_running():
|
||||
container.stop()
|
||||
container.destroy()
|
||||
# Run uninstall script
|
||||
self.run_script('uninstall')
|
||||
# Unregister app and remove scripts
|
||||
repo_local.unregister_app(self.name)
|
||||
try:
|
||||
shutil.rmtree(self.app_dir)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def start(self, observer=None):
|
||||
# Start all application containers
|
||||
if observer:
|
||||
observer.units_total = len(self.containers)
|
||||
for container in self.containers:
|
||||
container.start()
|
||||
if observer:
|
||||
observer.units_done += 1
|
||||
|
||||
def stop(self, observer=None):
|
||||
# Stop all application containers
|
||||
if observer:
|
||||
observer.units_total = len(self.containers)
|
||||
for container in self.containers:
|
||||
container.stop()
|
||||
if observer:
|
||||
observer.units_done += 1
|
||||
|
||||
def status(self):
|
||||
# Return status for all application containers
|
||||
return {container.name:container.get_state() for container in self.containers}
|
||||
|
||||
def is_running(self):
|
||||
# Convenience method to determine if any of the application's containers are running
|
||||
for container in self.containers:
|
||||
if container.is_running():
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_stopped(self):
|
||||
# Convenience method to determine if all of the application's containers are stopped
|
||||
return not self.is_running()
|
||||
|
||||
def set_autostart(self, autostart):
|
||||
# Configure if the application should be automatically started after boot
|
||||
self.autostart = autostart
|
||||
repo_local.register_app(self.name, self.get_definition())
|
||||
|
||||
def publish(self, filename):
|
||||
# Create application archive and register to publish repository
|
||||
builddir = os.path.dirname(filename)
|
||||
os.makedirs(config.PUB_APPS_DIR, 0o755, True)
|
||||
files = repo_publish.TarSizeCounter()
|
||||
archive_path = os.path.join(config.PUB_APPS_DIR, f'{self.name}.tar.xz')
|
||||
with tarfile.open(archive_path, 'w:xz') as tar:
|
||||
for content in ('install', 'install.sh', 'update', 'update.sh', 'uninstall', 'uninstall.sh'):
|
||||
content_path = os.path.join(builddir, content)
|
||||
if os.path.exists(content_path):
|
||||
tar.add(content_path, os.path.join(self.name, content), filter=files.add_file)
|
||||
with open(filename) as f:
|
||||
definition = json.load(f)
|
||||
definition['size'] = files.size
|
||||
definition['dlsize'] = os.path.getsize(archive_path)
|
||||
definition['hash'] = repo_publish.sign_file(archive_path).hex()
|
||||
repo_publish.register_app(self.name, definition)
|
||||
return (definition['size'], definition['dlsize'])
|
||||
|
||||
def unpublish(self):
|
||||
# Remove the application from publish repository
|
||||
repo_publish.unregister_app(self.name)
|
||||
archive_path = os.path.join(config.PUB_APPS_DIR, f'{self.name}.tar.xz')
|
||||
try:
|
||||
os.unlink(archive_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
@ -1,81 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from math import floor
|
||||
|
||||
SIZE_PREFIXES = ('', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y')
|
||||
|
||||
class ActionItem:
|
||||
def __init__(self, text, action):
|
||||
self.text = text
|
||||
self.action = action
|
||||
self.units_total = 0
|
||||
self.units_done = 0
|
||||
|
||||
def run(self):
|
||||
with ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(self.action, self)
|
||||
while not future.done():
|
||||
time.sleep(0.2)
|
||||
self.print_progress()
|
||||
# Get the result of the future and let it raise exception, if there was any
|
||||
future.result()
|
||||
self.print_progress('\n')
|
||||
|
||||
def print_progress(self, end='\r'):
|
||||
text = self.text
|
||||
if self.units_total:
|
||||
text = f'{text} ({self.units_done}/{self.units_total}) [{floor(self.units_done/self.units_total*100)} %]'
|
||||
print(f'\x1b[K{text}', end=end)
|
||||
|
||||
class ActionQueue:
|
||||
def __init__(self):
|
||||
self.queue = []
|
||||
|
||||
def download_image(self, image):
|
||||
self.queue.append(ActionItem(f'Downloading image {image.name}', image.download))
|
||||
self.queue.append(ActionItem(f'Unpacking image {image.name}', image.unpack_downloaded))
|
||||
|
||||
def delete_image(self, image):
|
||||
self.queue.append(ActionItem(f'Deleting image {image.name}', image.delete))
|
||||
|
||||
def install_app(self, app):
|
||||
self.queue.append(ActionItem(f'Downloading application {app.name}', app.download))
|
||||
self.queue.append(ActionItem(f'Unpacking application {app.name}', app.unpack_downloaded))
|
||||
self.queue.append(ActionItem(f'Installing application {app.name}', app.install))
|
||||
|
||||
def update_app(self, app):
|
||||
self.queue.append(ActionItem(f'Downloading application {app.name}', app.download))
|
||||
self.queue.append(ActionItem(f'Unpacking application {app.name}', app.unpack_downloaded))
|
||||
self.queue.append(ActionItem(f'Updating application {app.name}', app.update))
|
||||
|
||||
def uninstall_app(self, app):
|
||||
self.queue.append(ActionItem(f'Uninstalling application {app.name}', app.uninstall))
|
||||
|
||||
def start_app(self, app):
|
||||
self.queue.append(ActionItem(f'Starting application {app.name}', app.start))
|
||||
|
||||
def stop_app(self, app):
|
||||
self.queue.append(ActionItem(f'Stopping application {app.name}', app.stop))
|
||||
|
||||
def process(self):
|
||||
index = 0
|
||||
queue_length = len(self.queue)
|
||||
for item in self.queue:
|
||||
index += 1
|
||||
item.text = f'[{index}/{queue_length}] {item.text}'
|
||||
item.run()
|
||||
|
||||
def readable_size(bytes):
|
||||
i = 0
|
||||
while bytes > 1024:
|
||||
i += 1
|
||||
bytes /= 1024
|
||||
return f'{bytes:.2f} {SIZE_PREFIXES[i]}B'
|
||||
|
||||
def print_lock(pid):
|
||||
with open(os.path.join('/proc', pid, 'cmdline')) as f:
|
||||
cmdline = f.read().replace('\0', ' ').strip()
|
||||
print(f'Waiting for lock currently held by process {pid} - {cmdline}')
|
@ -1,47 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import configparser
|
||||
import os
|
||||
import urllib.parse
|
||||
|
||||
CONFIG_FILE = '/etc/spoc/spoc.conf'
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(CONFIG_FILE)
|
||||
|
||||
NETWORK_INTERFACE = config.get('general', 'network-interface', fallback='spocbr0')
|
||||
RESOLV_CONF = config.get('general', 'resolv-conf', fallback='/etc/resolv.conf')
|
||||
|
||||
DATA_DIR = config.get('general', 'data-dir', fallback='/var/lib/spoc/')
|
||||
APPS_DIR = os.path.join(DATA_DIR, 'apps/')
|
||||
CONTAINERS_DIR = os.path.join(DATA_DIR, 'containers/')
|
||||
LAYERS_DIR = os.path.join(DATA_DIR, 'layers/')
|
||||
VOLUMES_DIR = os.path.join(DATA_DIR, 'volumes/')
|
||||
HOSTS_FILE = os.path.join(DATA_DIR, 'hosts')
|
||||
REPO_FILE = os.path.join(DATA_DIR, 'repository.json')
|
||||
|
||||
LOCK_DIR = '/run/lock'
|
||||
LOCK_FILE = os.path.join(LOCK_DIR, 'spoc.lock')
|
||||
HOSTS_LOCK_FILE = os.path.join(LOCK_DIR, 'spoc-hosts.lock')
|
||||
REPO_LOCK_FILE = os.path.join(LOCK_DIR, 'spoc-local.lock')
|
||||
|
||||
TMP_DIR = os.path.join(DATA_DIR, 'tmp/')
|
||||
TMP_APPS_DIR = os.path.join(TMP_DIR, 'apps/')
|
||||
TMP_LAYERS_DIR = os.path.join(TMP_DIR, 'layers/')
|
||||
LOG_DIR = config.get('general', 'log-dir', fallback='/var/log/spoc')
|
||||
|
||||
PUB_DIR = config.get('publish', 'publish-dir', fallback=os.path.join(DATA_DIR, 'publish'))
|
||||
PUB_LAYERS_DIR = os.path.join(PUB_DIR, 'layers/')
|
||||
PUB_APPS_DIR = os.path.join(PUB_DIR, 'apps/')
|
||||
PUB_REPO_FILE = os.path.join(PUB_DIR, 'repository.json')
|
||||
PUB_SIG_FILE = os.path.join(PUB_DIR, 'repository.sig')
|
||||
PUB_PRIVKEY_FILE = config.get('publish', 'signing-key', fallback='/etc/spoc/publish.key')
|
||||
PUB_LOCK_FILE = os.path.join(LOCK_DIR, 'spoc-publish.lock')
|
||||
|
||||
# URLs which are an actual directories need to end with trailing slash
|
||||
ONLINE_BASE_URL = '{}/'.format(config.get('repo', 'url', fallback='https://localhost').rstrip('/'))
|
||||
ONLINE_LAYERS_URL = urllib.parse.urljoin(ONLINE_BASE_URL, 'layers/')
|
||||
ONLINE_APPS_URL = urllib.parse.urljoin(ONLINE_BASE_URL, 'apps/')
|
||||
ONLINE_REPO_URL = urllib.parse.urljoin(ONLINE_BASE_URL, 'repository.json')
|
||||
ONLINE_SIG_URL = urllib.parse.urljoin(ONLINE_BASE_URL, 'repository.sig')
|
||||
ONLINE_PUBKEY = config.get('repo', 'public-key', fallback='')
|
@ -1,279 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import copy
|
||||
import enum
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from . import config, net, repo_local, templates
|
||||
from .depsolver import DepSolver
|
||||
from .exceptions import InvalidContainerStateError
|
||||
|
||||
# States taken from https://github.com/lxc/lxc/blob/master/src/lxc/state.h
|
||||
class ContainerState(enum.Enum):
|
||||
STOPPED = 'STOPPED'
|
||||
STARTING = 'STARTING'
|
||||
RUNNING = 'RUNNING'
|
||||
STOPPING = 'STOPPING'
|
||||
ABORTING = 'ABORTING'
|
||||
FREEZING = 'FREEZING'
|
||||
FROZEN = 'FROZEN'
|
||||
THAWED = 'THAWED'
|
||||
UNKNOWN = 'UNKNOWN'
|
||||
|
||||
DEFINITION_MEMBERS = {'build', 'depends', 'layers', 'mounts', 'env', 'uid', 'gid', 'cmd', 'cwd', 'ready', 'halt'}
|
||||
|
||||
class Container:
|
||||
def __init__(self, name, load_from_repo=True):
|
||||
self.name = name
|
||||
self.build = False
|
||||
self.depends = []
|
||||
self.layers = []
|
||||
self.mounts = {}
|
||||
self.env = {}
|
||||
self.uid = None
|
||||
self.gid = None
|
||||
self.cmd = None
|
||||
self.cwd = None
|
||||
self.ready = None
|
||||
self.halt = None
|
||||
self.container_path = os.path.join(config.CONTAINERS_DIR, name)
|
||||
self.config_path = os.path.join(self.container_path, 'config')
|
||||
self.rootfs_path = os.path.join(self.container_path, 'rootfs')
|
||||
self.olwork_path = os.path.join(self.container_path, 'olwork')
|
||||
self.ephemeral_layer_path = os.path.join(self.container_path, 'ephemeral')
|
||||
self.log_path = os.path.join(config.LOG_DIR, f'{name}.log')
|
||||
if load_from_repo:
|
||||
self.set_definition(repo_local.get_container(name))
|
||||
|
||||
def set_definition(self, definition):
|
||||
# Set attributes given by definition
|
||||
for key in DEFINITION_MEMBERS.intersection(definition):
|
||||
setattr(self, key, definition[key])
|
||||
|
||||
def get_definition(self):
|
||||
# Return shallow copy of container definition as dictionary
|
||||
definition = {}
|
||||
for key in DEFINITION_MEMBERS:
|
||||
value = getattr(self, key)
|
||||
if value:
|
||||
definition[key] = copy.copy(value)
|
||||
return definition
|
||||
|
||||
def get_state(self):
|
||||
# Get current state of the container, uses LXC monitor socket accessible only in ocntainer's namespace
|
||||
try:
|
||||
state = subprocess.run(['lxc-info', '-sH', '-P', config.CONTAINERS_DIR, self.name], capture_output=True, check=True)
|
||||
return ContainerState[state.stdout.strip().decode()]
|
||||
except subprocess.CalledProcessError:
|
||||
return ContainerState.UNKNOWN
|
||||
|
||||
def is_running(self):
|
||||
# Convenience method to determine if the container is running
|
||||
return self.get_state() == ContainerState.RUNNING
|
||||
|
||||
def is_stopped(self):
|
||||
# Convenience method to determine if the container is stopped
|
||||
return self.get_state() == ContainerState.STOPPED
|
||||
|
||||
def await_state(self, awaited_state):
|
||||
# Block execution until the container reaches the desired state or until timeout
|
||||
try:
|
||||
subprocess.run(['lxc-wait', '-P', config.CONTAINERS_DIR, '-s', awaited_state.value, '-t', '30', self.name], check=True)
|
||||
except subprocess.CalledProcessError:
|
||||
# Sometimes LXC decides to return rc 1 even on successful state change
|
||||
actual_state = self.get_state()
|
||||
if actual_state != awaited_state:
|
||||
raise InvalidContainerStateError(self.name, actual_state)
|
||||
|
||||
def mount_rootfs(self):
|
||||
# Prepares container rootfs
|
||||
# Called in lxc.hook.pre-start as the standard mount options are insufficient for rootless containers (see notes for overlayfs below)
|
||||
layers = [os.path.join(config.LAYERS_DIR, layer) for layer in self.layers]
|
||||
if not self.build:
|
||||
# Add ephemeral layer if the container is not created as part of build process
|
||||
layers.append(self.ephemeral_layer_path)
|
||||
if len(layers) > 1:
|
||||
# Multiple layers require overlayfs, however non-root users don't normally have capability to create overlayfs mounts - https://www.spinics.net/lists/linux-fsdevel/msg105877.html
|
||||
# Standard linux kernels currently doesn't support overlay mounts in user namespaces (lxc.hook.pre-mount)
|
||||
# The exception is Ubuntu or custom patches such as https://salsa.debian.org/kernel-team/linux/blob/master/debian/patches/debian/overlayfs-permit-mounts-in-userns.patch
|
||||
# Possible alternative is fuse-overlayfs, which doesn't work well on Alpine (and it's FUSE anyway, so it needs an extra service and a process for each mount)
|
||||
# Another alternative would be to mount in the namespace via -N option, but LXC doesn't expose PID or namespaces of the process during container setup
|
||||
overlay_opts = f'upperdir={layers[-1]},lowerdir={":".join(reversed(layers[:-1]))},workdir={self.olwork_path}'
|
||||
subprocess.run(['mount', '-t', 'overlay', '-o', overlay_opts, 'none', self.rootfs_path])
|
||||
else:
|
||||
# We only have a single layer, no overlay needed
|
||||
subprocess.run(['mount', '--bind', layers[0], self.rootfs_path])
|
||||
|
||||
def unmount_rootfs(self):
|
||||
# Recursively unmounts container rootfs
|
||||
# Called in lxc.hook.post-stop
|
||||
# For unprivileged containers it could theoretically be called already in lxc.hook.start-host, as the user namespace clones the mounts,
|
||||
# so they are not needed in the parent namespace anymore, but removing rootfs on container stop seems more intuitive
|
||||
subprocess.run(['umount', '-R', self.rootfs_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
def clean_ephemeral_layer(self):
|
||||
# Cleans container 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
|
||||
for item in os.scandir(self.ephemeral_layer_path):
|
||||
shutil.rmtree(item.path) if item.is_dir() else os.unlink(item.path)
|
||||
|
||||
def get_mount_entry(self, volume, mountpoint):
|
||||
mount_type = 'dir'
|
||||
if mountpoint.endswith(':file'):
|
||||
mount_type = 'file'
|
||||
mountpoint = mountpoint[:-5]
|
||||
return f'lxc.mount.entry = {os.path.join(config.VOLUMES_DIR, volume)} {mountpoint} none bind,create={mount_type} 0 0'
|
||||
|
||||
def create(self):
|
||||
# Create container directories
|
||||
os.makedirs(self.rootfs_path, 0o755, True)
|
||||
os.makedirs(self.olwork_path, 0o755, True)
|
||||
os.makedirs(self.ephemeral_layer_path, 0o755, True)
|
||||
os.makedirs(config.LOG_DIR, 0o750, True)
|
||||
# Change UID/GID of the ephemeral layer directory
|
||||
# Chown is possible only when the process is running as root, for user namespaces, see https://linuxcontainers.org/lxc/manpages/man1/lxc-usernsexec.1.html
|
||||
os.chown(self.ephemeral_layer_path, 100000, 100000)
|
||||
# Create container configuration file based on the container definition
|
||||
mounts = '\n'.join([self.get_mount_entry(v, m) for v,m in self.mounts.items()])
|
||||
env = '\n'.join([f'lxc.environment = {k}={v}' for k,v in self.env.items()])
|
||||
uid = self.uid if self.uid else 0
|
||||
gid = self.gid if self.gid else 0
|
||||
cmd = self.cmd if self.cmd else '/sbin/init'
|
||||
cwd = self.cwd if self.cwd else '/'
|
||||
halt = self.halt if self.halt else 'SIGINT'
|
||||
ip_address, ip_netmask, ip_gateway = net.request_ip(self.name)
|
||||
# Write LXC configuration file
|
||||
with open(self.config_path, 'w') as f:
|
||||
f.write(templates.LXC_CONTAINER_TEMPLATE.format(name=self.name,
|
||||
interface=config.NETWORK_INTERFACE,
|
||||
resolv_conf=config.RESOLV_CONF,
|
||||
ip_address=ip_address,
|
||||
ip_netmask=ip_netmask,
|
||||
ip_gateway=ip_gateway,
|
||||
rootfs=self.rootfs_path,
|
||||
hosts=config.HOSTS_FILE,
|
||||
mounts=mounts,
|
||||
env=env,
|
||||
uid=uid,
|
||||
gid=gid,
|
||||
cmd=cmd,
|
||||
cwd=cwd,
|
||||
halt=halt,
|
||||
log=self.log_path))
|
||||
repo_local.register_container(self.name, self.get_definition())
|
||||
|
||||
def destroy(self):
|
||||
repo_local.unregister_container(self.name)
|
||||
self.unmount_rootfs()
|
||||
try:
|
||||
shutil.rmtree(self.container_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
try:
|
||||
os.unlink(self.log_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
# Release the IP address from global hosts configuration
|
||||
net.release_ip(self.name)
|
||||
|
||||
def start(self, command=None):
|
||||
# Start the container including its dependencies
|
||||
depsolver = DepSolver()
|
||||
self.get_start_dependencies(depsolver)
|
||||
for dependency in depsolver.solve():
|
||||
if not dependency.is_running():
|
||||
# Pass start command only to the current container
|
||||
dependency.do_start(command if dependency.name == self.name else None)
|
||||
|
||||
def do_start(self, command=None):
|
||||
cmd = ['--']+command if command else []
|
||||
# Start the current container, wait until it is reported as started and execute application readiness check
|
||||
subprocess.Popen(['lxc-start', '-P', config.CONTAINERS_DIR, self.name]+cmd)
|
||||
self.await_state(ContainerState.RUNNING)
|
||||
# Launch the readiness check in a separate thread, so it can be reliably cancelled after timeout
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
# Create anonymous object to pass the task cancellation information
|
||||
guard = type('', (object,), {'cancel': False})()
|
||||
future = pool.submit(self.check_readiness, guard)
|
||||
future.result(timeout=30)
|
||||
guard.cancel = True
|
||||
|
||||
def check_readiness(self, guard):
|
||||
# Run spoc.init.ready until it returns return code 0 or the guard cancels the loop
|
||||
ready_cmd = shlex.split(self.ready) if self.ready else ['/bin/true']
|
||||
while not guard.cancel:
|
||||
state = self.get_state()
|
||||
if state != ContainerState.RUNNING:
|
||||
raise InvalidContainerStateError(self.name, state)
|
||||
check = subprocess.run(['lxc-attach', '-P', config.CONTAINERS_DIR, '--clear-env', self.name, '--']+ready_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30)
|
||||
if check.returncode == 0:
|
||||
break
|
||||
time.sleep(0.25)
|
||||
|
||||
def stop(self):
|
||||
# Stop the containers depending on the current cotnainer
|
||||
depsolver = DepSolver()
|
||||
self.get_stop_dependencies(depsolver)
|
||||
for dependency in depsolver.solve():
|
||||
if not dependency.is_stopped():
|
||||
dependency.do_stop()
|
||||
|
||||
def do_stop(self):
|
||||
# Stop the current container and wait until it stops completely
|
||||
lxc_stop = subprocess.Popen(['lxc-stop', '-P', config.CONTAINERS_DIR, self.name])
|
||||
self.await_state(ContainerState.STOPPED)
|
||||
# Reap the lxc-stop process
|
||||
lxc_stop.wait()
|
||||
|
||||
def execute(self, cmd, uid=None, gid=None, **kwargs):
|
||||
# If the container is starting or stopping, wait until the operation is finished
|
||||
state = self.get_state()
|
||||
if state == ContainerState.STARTING:
|
||||
self.await_state(ContainerState.RUNNING)
|
||||
state = self.get_state()
|
||||
elif state == ContainerState.STOPPING:
|
||||
self.await_state(ContainerState.STOPPED)
|
||||
state = self.get_state()
|
||||
# Resolve UID/GID, if they have been given
|
||||
uidgid_param = []
|
||||
uid,gid = self.get_uidgid(uid, gid)
|
||||
if uid:
|
||||
uidgid_param.extend(('-u', uid))
|
||||
if gid:
|
||||
uidgid_param.extend(('-g', gid))
|
||||
# If the container is stopped, use lxc-execute, otherwise use lxc-attach
|
||||
if state == ContainerState.STOPPED:
|
||||
return subprocess.run(['lxc-execute', '-P', config.CONTAINERS_DIR]+uidgid_param+[self.name, '--']+cmd, **kwargs)
|
||||
elif state == ContainerState.RUNNING:
|
||||
return subprocess.run(['lxc-attach', '-P', config.CONTAINERS_DIR, '--clear-env']+uidgid_param+[self.name, '--']+cmd, **kwargs)
|
||||
else:
|
||||
raise InvalidContainerStateError(self.name, state)
|
||||
|
||||
def get_uidgid(self, user=None, group=None):
|
||||
# Helper function to get UID/GID of an user/group from the container
|
||||
uid,gid = None,None
|
||||
if user:
|
||||
uid_entry = self.execute(['/usr/bin/getent', 'passwd', user], capture_output=True, check=True).stdout.decode().split(':')
|
||||
uid,gid = uid_entry[2],uid_entry[3]
|
||||
if group:
|
||||
gid = self.execute(['/usr/bin/getent', 'group', group], capture_output=True, check=True).stdout.decode().split(':')[2]
|
||||
return (uid,gid)
|
||||
|
||||
def get_start_dependencies(self, depsolver):
|
||||
depsolver.add(self.name, self.depends, self)
|
||||
for dependency in self.depends:
|
||||
Container(dependency).get_start_dependencies(depsolver)
|
||||
|
||||
def get_stop_dependencies(self, depsolver):
|
||||
reverse_depends = []
|
||||
for name, definition in repo_local.get_containers().items():
|
||||
if 'depends' in definition and self.name in definition['depends']:
|
||||
reverse_depends.append(name)
|
||||
depsolver.add(self.name, reverse_depends, self)
|
||||
for dependency in reverse_depends:
|
||||
Container(dependency).get_stop_dependencies(depsolver)
|
@ -1,36 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from .exceptions import CircularDependencyError
|
||||
|
||||
class Node:
|
||||
def __init__(self, name, depends, instance):
|
||||
self.name = name
|
||||
# Remove the node from its own dependencies
|
||||
self.depends = set(depends) - {name}
|
||||
self.instance = instance
|
||||
|
||||
class DepSolver:
|
||||
def __init__(self):
|
||||
self.nodes = []
|
||||
|
||||
def add(self, name, depends, instance):
|
||||
self.nodes.append(Node(name, depends, instance))
|
||||
|
||||
def solve(self):
|
||||
# Returns a list of instances ordered by dependency
|
||||
deps = {node.name: node for node in self.nodes}
|
||||
result = []
|
||||
while deps:
|
||||
# Get a batch of nodes not depending on anything (or originally depending on already resolved nodes)
|
||||
batch = {name for name, node in deps.items() if not node.depends}
|
||||
if not batch:
|
||||
# If there are no such nodes, we have found a circular dependency
|
||||
raise CircularDependencyError(deps)
|
||||
# Add instances tied to the resolved keys to the result and remove resolved keys from the dependecy map
|
||||
for name in batch:
|
||||
result.append(deps[name].instance)
|
||||
del deps[name]
|
||||
# Remove resolved keys from the dependencies of yet unresolved nodes
|
||||
for node in deps.values():
|
||||
node.depends -= batch
|
||||
return result
|
@ -1,41 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
class AppNotFoundError(Exception):
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def __str__(self):
|
||||
return f'Application {self.name} not found'
|
||||
|
||||
class ContainerNotFoundError(Exception):
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def __str__(self):
|
||||
return f'Container {self.name} not found'
|
||||
|
||||
class ImageNotFoundError(Exception):
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def __str__(self):
|
||||
return f'Image {self.name} not found'
|
||||
|
||||
class InvalidContainerStateError(Exception):
|
||||
# Container is not in expected state (running, stopped etc.)
|
||||
def __init__(self, container_name, container_state):
|
||||
self.container_name = container_name
|
||||
self.container_state = container_state
|
||||
|
||||
def __str__(self):
|
||||
return f'Container "{self.container_name}" reached unexpected state {self.container_state}'
|
||||
|
||||
class CircularDependencyError(Exception):
|
||||
# Dependecy solver has found a circular dependency between nodes
|
||||
def __init__(self, deps):
|
||||
self.deps = deps
|
||||
|
||||
def __str__(self):
|
||||
result = ['Dependency resolution failed due to circular dependency. Dumping unresolved dependencies:']
|
||||
result.extend(f'{dep} => {node.depends}' for dep, node in self.deps.items())
|
||||
return '\n'.join(result)
|
@ -1,48 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import errno
|
||||
import fcntl
|
||||
import os
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
|
||||
@contextmanager
|
||||
def lock(lock_file, fail_callback=None):
|
||||
with open(lock_file, 'a'):
|
||||
# Open the lock file in append mode first to ensure its existence but not modify any data if it already exists
|
||||
pass
|
||||
# Open the lock file in read + write mode without truncation
|
||||
with open(lock_file, 'r+') as f:
|
||||
while True:
|
||||
try:
|
||||
# Try to obtain exclusive lock in non-blocking mode
|
||||
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except OSError as e:
|
||||
# If lock is already locked by another process
|
||||
if e.errno == errno.EAGAIN:
|
||||
if fail_callback:
|
||||
# Call the callback function with contents of the lock file (PID of the process holding the lock)
|
||||
fail_callback(f.read())
|
||||
# Remove the callback function so it's not called in every loop
|
||||
fail_callback = None
|
||||
# Set the position for future truncation
|
||||
f.seek(0)
|
||||
# Wait for the lock to be freed
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
raise
|
||||
# If the lock was obtained, truncate the file and write PID of the process holding the lock
|
||||
f.truncate()
|
||||
f.write(str(os.getpid()))
|
||||
f.flush()
|
||||
yield f
|
||||
|
||||
# Function decorator
|
||||
def locked(lock_file, fail_callback=None):
|
||||
def decorator(target):
|
||||
def wrapper(*args, **kwargs):
|
||||
with lock(lock_file, fail_callback):
|
||||
return target(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
@ -1,99 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import copy
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
import urllib.parse
|
||||
|
||||
from . import config, repo_local, repo_online, repo_publish
|
||||
|
||||
DEFINITION_MEMBERS = {'layers', 'env', 'uid', 'gid', 'cmd', 'cwd', 'ready', 'halt'}
|
||||
|
||||
class Image:
|
||||
def __init__(self, name, load_from_repo=True):
|
||||
self.name = name
|
||||
self.layer_path = os.path.join(config.LAYERS_DIR, name)
|
||||
self.layers = [name]
|
||||
self.env = {}
|
||||
self.uid = None
|
||||
self.gid = None
|
||||
self.cmd = None
|
||||
self.cwd = None
|
||||
self.ready = None
|
||||
self.halt = None
|
||||
if load_from_repo:
|
||||
self.set_definition(repo_local.get_image(name))
|
||||
|
||||
def set_definition(self, definition):
|
||||
# Set attributes given by definition
|
||||
for key in DEFINITION_MEMBERS.intersection(definition):
|
||||
setattr(self, key, definition[key])
|
||||
|
||||
def get_definition(self):
|
||||
# Return shallow copy of image definition as dictionary
|
||||
definition = {}
|
||||
for key in DEFINITION_MEMBERS:
|
||||
value = getattr(self, key)
|
||||
if value:
|
||||
definition[key] = copy.copy(value)
|
||||
return definition
|
||||
|
||||
def create(self, imagebuilder, filename):
|
||||
# Build the container from image file and save to local repository
|
||||
# Chown is possible only when the process is running as root, for user namespaces, see https://linuxcontainers.org/lxc/manpages/man1/lxc-usernsexec.1.html
|
||||
os.makedirs(self.layer_path, 0o755, True)
|
||||
os.chown(self.layer_path, 100000, 100000)
|
||||
imagebuilder.build(self, filename)
|
||||
repo_local.register_image(self.name, self.get_definition())
|
||||
|
||||
def delete(self, observer=None):
|
||||
# Remove the layer from local repository and filesystem
|
||||
repo_local.unregister_image(self.name)
|
||||
try:
|
||||
shutil.rmtree(self.layer_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def download(self, observer=None):
|
||||
# Download the archive with layer data
|
||||
os.makedirs(config.TMP_LAYERS_DIR, 0o700, True)
|
||||
archive_url = urllib.parse.urljoin(config.ONLINE_LAYERS_URL, f'{self.name}.tar.xz')
|
||||
archive_path = os.path.join(config.TMP_LAYERS_DIR, f'{self.name}.tar.xz')
|
||||
definition = repo_online.get_image(self.name)
|
||||
if observer:
|
||||
observer.units_total = definition['dlsize']
|
||||
repo_online.download_archive(archive_url, archive_path, definition['hash'], observer)
|
||||
|
||||
def unpack_downloaded(self, observer=None):
|
||||
# Unpack downloaded archive with layer data
|
||||
archive_path = os.path.join(config.TMP_LAYERS_DIR, f'{self.name}.tar.xz')
|
||||
definition = repo_online.get_image(self.name)
|
||||
if observer:
|
||||
observer.units_total = definition['size']
|
||||
repo_online.unpack_archive(archive_path, config.LAYERS_DIR, definition['hash'], observer)
|
||||
self.set_definition(definition)
|
||||
repo_local.register_image(self.name, definition)
|
||||
|
||||
def publish(self):
|
||||
# Create layer archive and register to publish repository
|
||||
os.makedirs(config.PUB_LAYERS_DIR, 0o755, True)
|
||||
files = repo_publish.TarSizeCounter()
|
||||
archive_path = os.path.join(config.PUB_LAYERS_DIR, f'{self.name}.tar.xz')
|
||||
with tarfile.open(archive_path, 'w:xz') as tar:
|
||||
tar.add(self.layer_path, self.name, filter=files.add_file)
|
||||
definition = self.get_definition()
|
||||
definition['size'] = files.size
|
||||
definition['dlsize'] = os.path.getsize(archive_path)
|
||||
definition['hash'] = repo_publish.sign_file(archive_path).hex()
|
||||
repo_publish.register_image(self.name, definition)
|
||||
return (definition['size'], definition['dlsize'])
|
||||
|
||||
def unpublish(self):
|
||||
# Remove the layer from publish repository
|
||||
repo_publish.unregister_image(self.name)
|
||||
archive_path = os.path.join(config.PUB_LAYERS_DIR, f'{self.name}.tar.xz')
|
||||
try:
|
||||
os.unlink(archive_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
@ -1,177 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import requests
|
||||
import shutil
|
||||
import stat
|
||||
import tarfile
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
from .container import Container
|
||||
from .image import Image
|
||||
|
||||
class ImageBuilder:
|
||||
def build(self, image, filename):
|
||||
# Reset internal state, read and process lines from filename
|
||||
self.image = image
|
||||
self.builddir = os.path.dirname(filename)
|
||||
self.script_eof = None
|
||||
self.script_lines = []
|
||||
with open(filename, 'r') as f:
|
||||
for line in f:
|
||||
self.process_line(line.strip())
|
||||
|
||||
def process_line(self, line):
|
||||
# Parse a line from image file
|
||||
if self.script_eof:
|
||||
if line == self.script_eof:
|
||||
self.script_eof = None
|
||||
self.run_script(self.script_lines)
|
||||
else:
|
||||
self.script_lines.append(line)
|
||||
elif line:
|
||||
self.process_directive(*line.split(None, 1))
|
||||
|
||||
def process_directive(self, directive, args):
|
||||
# Process a directive from image file
|
||||
if 'RUN' == directive:
|
||||
self.script_lines = []
|
||||
self.script_eof = args
|
||||
elif 'FROM' == directive:
|
||||
# Set the values of image from which this one inherits
|
||||
self.image.set_definition(Image(args).get_definition())
|
||||
self.image.layers.append(self.image.name)
|
||||
elif 'COPY' == directive:
|
||||
srcdst = args.split()
|
||||
self.copy_files(srcdst[0], srcdst[1] if len(srcdst) > 1 else '')
|
||||
elif 'ENV' == directive:
|
||||
# Sets/unsets environment variable
|
||||
self.set_env(*args.split(None, 1))
|
||||
elif 'USER' == directive:
|
||||
# Sets init UID / GID
|
||||
self.set_uidgid(*args.split())
|
||||
elif 'CMD' == directive:
|
||||
# Sets init command
|
||||
self.image.cmd = args
|
||||
elif 'WORKDIR' == directive:
|
||||
# Sets init working directory
|
||||
self.image.cwd = args
|
||||
elif 'HALT' == directive:
|
||||
# Sets signal to be sent to init when stopping the container
|
||||
self.image.halt = args
|
||||
elif 'READY' == directive:
|
||||
# Sets a command to check readiness of the container after it has been started
|
||||
self.image.ready = args
|
||||
|
||||
def run_script(self, script_lines):
|
||||
# Creates a temporary container, runs a script in its namespace, and stores the files modified by it as part of the layer
|
||||
# Note: If USER or WORKDIR directive has already been set, the command is run under that UID/GID or working directory
|
||||
script_fd, script_path = tempfile.mkstemp(suffix='.sh', dir=self.image.layer_path, text=True)
|
||||
script_name = os.path.basename(script_path)
|
||||
script_lines = '\n'.join(script_lines)
|
||||
with os.fdopen(script_fd, 'w') as script:
|
||||
script.write(f'#!/bin/sh\nset -ev\n\n{script_lines}\n')
|
||||
os.chmod(script_path, 0o755)
|
||||
os.chown(script_path, 100000, 100000)
|
||||
# Create a temporary container from the current image definition and execute the script within the container
|
||||
container = Container(self.image.name, False)
|
||||
container.set_definition(self.image.get_definition())
|
||||
container.build = True
|
||||
container.create()
|
||||
container.execute(['/bin/sh', '-lc', os.path.join('/', script_name)], check=True)
|
||||
container.destroy()
|
||||
os.unlink(script_path)
|
||||
|
||||
def set_env(self, key, value=None):
|
||||
# Set or unset environement variable
|
||||
if value:
|
||||
self.image.env[key] = value
|
||||
else:
|
||||
try:
|
||||
del self.image.env[key]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def set_uidgid(self, uid, gid=''):
|
||||
# Set UID/GID for init
|
||||
if not uid.isdigit() or not gid.isdigit():
|
||||
# Resolve the UID/GID from container if either of them is entered as string
|
||||
container = Container(self.image.name, False)
|
||||
container.set_definition(self.image.get_definition())
|
||||
container.create()
|
||||
uid,gid = container.get_uidgid(uid, gid)
|
||||
container.destroy()
|
||||
self.image.uid = uid
|
||||
self.image.gid = gid
|
||||
|
||||
def copy_files(self, src, dst):
|
||||
# Copy files from the host or download them from a http(s) URL
|
||||
dst = os.path.join(self.image.layer_path, dst.lstrip('/'))
|
||||
if src.startswith('http://') or src.startswith('https://'):
|
||||
unpack_http_archive(src, dst)
|
||||
else:
|
||||
src = os.path.join(self.builddir, src)
|
||||
if os.path.isdir(src):
|
||||
copy_tree(src, dst)
|
||||
else:
|
||||
shutil.copy2(src, dst)
|
||||
# Shift UID/GID of the files to the unprivileged range
|
||||
shift_uid(dst, os.stat(dst, follow_symlinks=False))
|
||||
|
||||
def unpack_http_archive(src, dst):
|
||||
# Decompress an archive downloaded via http(s)
|
||||
with tempfile.TemporaryFile() as tmp_archive:
|
||||
# Download the file via http(s) and store as temporary file
|
||||
with requests.Session() as session:
|
||||
resource = session.get(src, stream=True)
|
||||
resource.raise_for_status()
|
||||
for chunk in resource.iter_content(chunk_size=None):
|
||||
if chunk:
|
||||
tmp_archive.write(chunk)
|
||||
# Check if the magic bytes and determine if the file is zip
|
||||
tmp_archive.seek(0)
|
||||
is_zip = zipfile.is_zipfile(tmp_archive)
|
||||
# Extract the file. If it is not zip, assume tar (bzip2, gizp or xz)
|
||||
tmp_archive.seek(0)
|
||||
if is_zip:
|
||||
with zipfile.ZipFile(tmp_archive) as zip:
|
||||
zip.extractall(dst)
|
||||
else:
|
||||
with tarfile.open(fileobj=tmp_archive) as tar:
|
||||
tar.extractall(dst, numeric_owner=True)
|
||||
|
||||
def copy_tree(src, dst):
|
||||
# Copy directory tree from host to container, leaving the existing modes and attributed unchanged,
|
||||
# which is crucial e.g. whenever anything is copied into /tmp
|
||||
# This function is a stripped and customized variant of shutil.copytree()
|
||||
for srcentry in os.scandir(src):
|
||||
dstname = os.path.join(dst, srcentry.name)
|
||||
is_new = not os.path.exists(dstname)
|
||||
if srcentry.is_dir():
|
||||
if is_new:
|
||||
os.mkdir(dstname)
|
||||
copy_tree(srcentry, dstname)
|
||||
else:
|
||||
shutil.copy2(srcentry, dstname)
|
||||
if is_new:
|
||||
shutil.copystat(srcentry, dstname, follow_symlinks=False)
|
||||
|
||||
def shift_uid(path, path_stat):
|
||||
# Shifts UID/GID of a file or a directory and its contents to the unprivileged range
|
||||
# The function parameters could arguably be more friendly, but os.scandir() already calls stat() on the entires,
|
||||
# so it would be wasteful to not reuse them for considerable performance gain
|
||||
uid = path_stat.st_uid
|
||||
gid = path_stat.st_gid
|
||||
do_chown = False
|
||||
if uid < 100000:
|
||||
uid = uid + 100000
|
||||
do_chown = True
|
||||
if gid < 100000:
|
||||
gid = gid + 100000
|
||||
do_chown = True
|
||||
if do_chown:
|
||||
os.chown(path, uid, gid, follow_symlinks=False)
|
||||
if stat.S_ISDIR(path_stat.st_mode):
|
||||
for entry in os.scandir(path):
|
||||
shift_uid(entry.path, entry.stat(follow_symlinks=False))
|
@ -1,81 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import fcntl
|
||||
import ipaddress
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
|
||||
from . import config
|
||||
from .flock import locked
|
||||
|
||||
# ioctl magic constants taken from https://git.musl-libc.org/cgit/musl/tree/include/sys/ioctl.h (same as glibc)
|
||||
IOCTL_SIOCGIFADDR = 0x8915
|
||||
IOCTL_SIOCGIFNETMASK = 0x891b
|
||||
|
||||
leases = {}
|
||||
mtime = None
|
||||
|
||||
@locked(config.HOSTS_LOCK_FILE)
|
||||
def load_leases():
|
||||
# Read and parse all IP-hostname pairs from the global hosts file
|
||||
global leases
|
||||
global mtime
|
||||
try:
|
||||
file_mtime = os.stat(config.HOSTS_FILE).st_mtime
|
||||
if mtime != file_mtime:
|
||||
with open(config.HOSTS_FILE, 'r') as f:
|
||||
leases = [lease.strip().split(None, 1) for lease in f]
|
||||
leases = {ip: hostname for ip, hostname in leases}
|
||||
mtime = file_mtime
|
||||
except FileNotFoundError:
|
||||
# If the file doesn't exist, create it with localhost and container host as default records
|
||||
interface = get_bridge_interface()
|
||||
leases = {'127.0.0.1': 'localhost', str(interface.ip): 'host'}
|
||||
|
||||
@locked(config.HOSTS_LOCK_FILE)
|
||||
def save_leases():
|
||||
# write all IP-hostname pairs to the global hosts file
|
||||
global mtime
|
||||
with open(config.HOSTS_FILE, 'w') as f:
|
||||
for ip,hostname in sorted(leases.items(), key=lambda lease: socket.inet_aton(lease[0])):
|
||||
f.write(f'{ip} {hostname}\n')
|
||||
mtime = os.stat(config.HOSTS_FILE).st_mtime
|
||||
|
||||
def get_bridge_interface():
|
||||
# Returns bridge interface's IP address and netmask
|
||||
with socket.socket(socket.AF_INET) as sock:
|
||||
# Get IPv4Interface for given interface name
|
||||
packed_ifname = struct.pack('256s', config.NETWORK_INTERFACE.encode())
|
||||
ip = socket.inet_ntoa(fcntl.ioctl(sock.fileno(), IOCTL_SIOCGIFADDR, packed_ifname)[20:24])
|
||||
netmask = socket.inet_ntoa(fcntl.ioctl(sock.fileno(), IOCTL_SIOCGIFNETMASK, packed_ifname)[20:24])
|
||||
return ipaddress.IPv4Interface(f'{ip}/{netmask}')
|
||||
|
||||
def get_ip(container_name):
|
||||
load_leases()
|
||||
for ip,hostname in leases.items():
|
||||
if hostname == container_name:
|
||||
return ip
|
||||
return None
|
||||
|
||||
def request_ip(container_name):
|
||||
# Find if and IP hasn't been leased for the hostname
|
||||
interface = get_bridge_interface()
|
||||
load_leases()
|
||||
for ip in leases:
|
||||
if leases[ip] == container_name:
|
||||
return (ip, str(interface.network.prefixlen), str(interface.ip))
|
||||
# If not, get the first unassigned IP from the interface's network
|
||||
for ip in interface.network.hosts():
|
||||
ip = str(ip)
|
||||
if ip not in leases:
|
||||
leases[ip] = container_name
|
||||
save_leases()
|
||||
return (ip, str(interface.network.prefixlen), str(interface.ip))
|
||||
|
||||
def release_ip(container_name):
|
||||
# Delete the lease from hosts file
|
||||
global leases
|
||||
load_leases()
|
||||
leases = {ip: h for ip, h in leases.items() if h != container_name}
|
||||
save_leases()
|
@ -1,96 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
|
||||
from . import config
|
||||
from .exceptions import AppNotFoundError, ContainerNotFoundError, ImageNotFoundError
|
||||
from .flock import locked
|
||||
|
||||
TYPE_APP = 'apps'
|
||||
TYPE_CONTAINER = 'containers'
|
||||
TYPE_IMAGE = 'images'
|
||||
|
||||
data = {TYPE_IMAGE: {}, TYPE_CONTAINER: {}, TYPE_APP: {}}
|
||||
mtime = 0
|
||||
|
||||
def load():
|
||||
global data
|
||||
global mtime
|
||||
try:
|
||||
file_mtime = os.stat(config.REPO_FILE).st_mtime
|
||||
if mtime != file_mtime:
|
||||
with open(config.REPO_FILE) as f:
|
||||
data = json.load(f)
|
||||
mtime = file_mtime
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def save():
|
||||
global mtime
|
||||
with open(config.REPO_FILE, 'w') as f:
|
||||
json.dump(data, f, sort_keys=True, indent=4)
|
||||
mtime = os.stat(config.REPO_FILE).st_mtime
|
||||
|
||||
@locked(config.REPO_LOCK_FILE)
|
||||
def get_entries(entry_type):
|
||||
load()
|
||||
return data[entry_type]
|
||||
|
||||
def get_entry(entry_type, name, exception):
|
||||
try:
|
||||
return get_entries(entry_type)[name]
|
||||
except KeyError as e:
|
||||
raise exception(name) from e
|
||||
|
||||
@locked(config.REPO_LOCK_FILE)
|
||||
def add_entry(entry_type, name, definition):
|
||||
load()
|
||||
data[entry_type][name] = definition
|
||||
save()
|
||||
|
||||
@locked(config.REPO_LOCK_FILE)
|
||||
def delete_entry(entry_type, name):
|
||||
load()
|
||||
try:
|
||||
del data[entry_type][name]
|
||||
save()
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def get_images():
|
||||
return get_entries(TYPE_IMAGE)
|
||||
|
||||
def get_image(image_name):
|
||||
return get_entry(TYPE_IMAGE, image_name, ImageNotFoundError)
|
||||
|
||||
def register_image(image_name, definition):
|
||||
add_entry(TYPE_IMAGE, image_name, definition)
|
||||
|
||||
def unregister_image(image_name):
|
||||
delete_entry(TYPE_IMAGE, image_name)
|
||||
|
||||
def get_containers():
|
||||
return get_entries(TYPE_CONTAINER)
|
||||
|
||||
def get_container(container_name):
|
||||
return get_entry(TYPE_CONTAINER, container_name, ContainerNotFoundError)
|
||||
|
||||
def register_container(container_name, definition):
|
||||
add_entry(TYPE_CONTAINER, container_name, definition)
|
||||
|
||||
def unregister_container(container_name):
|
||||
delete_entry(TYPE_CONTAINER, container_name)
|
||||
|
||||
def get_apps():
|
||||
return get_entries(TYPE_APP)
|
||||
|
||||
def get_app(app_name):
|
||||
return get_entry(TYPE_APP, app_name, AppNotFoundError)
|
||||
|
||||
def register_app(app_name, definition):
|
||||
add_entry(TYPE_APP, app_name, definition)
|
||||
|
||||
def unregister_app(app_name):
|
||||
delete_entry(TYPE_APP, app_name)
|
@ -1,131 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import json
|
||||
import os
|
||||
import requests
|
||||
import shutil
|
||||
import tarfile
|
||||
import time
|
||||
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, utils
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
||||
|
||||
from . import config
|
||||
from .exceptions import AppNotFoundError, ImageNotFoundError
|
||||
|
||||
TYPE_APP = 'apps'
|
||||
TYPE_IMAGE = 'images'
|
||||
|
||||
public_key = None
|
||||
|
||||
def get_public_key():
|
||||
global public_key
|
||||
if not public_key:
|
||||
pem = f'-----BEGIN PUBLIC KEY-----\n{config.ONLINE_PUBKEY}\n-----END PUBLIC KEY-----'
|
||||
public_key = load_pem_public_key(pem.encode(), default_backend())
|
||||
return public_key
|
||||
|
||||
def verify_fileobj(fileobj, expected_hash):
|
||||
hasher = hashes.Hash(hashes.SHA512(), default_backend())
|
||||
while True:
|
||||
data = fileobj.read(64*1024)
|
||||
if not data:
|
||||
break
|
||||
hasher.update(data)
|
||||
get_public_key().verify(bytes.fromhex(expected_hash), hasher.finalize(), ec.ECDSA(utils.Prehashed(hashes.SHA512())))
|
||||
|
||||
def download_archive(archive_url, archive_path, expected_hash, observer=None):
|
||||
# Check if an archive needs to be downloaded via http(s)
|
||||
do_download = True
|
||||
# If the file already exists in the temporary directory, verify the signature
|
||||
if os.path.exists(archive_path):
|
||||
try:
|
||||
with open(archive_path, 'rb') as f:
|
||||
verify_fileobj(f, expected_hash)
|
||||
# If the signature matches, skip download
|
||||
if observer:
|
||||
observer.units_done = os.path.getsize(archive_path)
|
||||
do_download = False
|
||||
except InvalidSignature:
|
||||
# If the signature is invalid, redownload the file
|
||||
pass
|
||||
if do_download:
|
||||
do_download_archive(archive_url, archive_path, expected_hash, observer)
|
||||
|
||||
def do_download_archive(archive_url, archive_path, expected_hash, observer=None):
|
||||
# Download archive via http(s) and store in temporary directory
|
||||
with open(archive_path, 'wb') as f, requests.Session() as session:
|
||||
resource = session.get(archive_url, stream=True)
|
||||
resource.raise_for_status()
|
||||
if observer:
|
||||
for chunk in resource.iter_content(chunk_size=64*1024):
|
||||
if chunk:
|
||||
observer.units_done += f.write(chunk)
|
||||
else:
|
||||
for chunk in resource.iter_content(chunk_size=64*1024):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
|
||||
def unpack_archive(archive_path, destination, expected_hash, observer):
|
||||
with open(archive_path, 'rb') as f:
|
||||
# Verify file object, then seek back and open it as tar without losing handle, preventing possible malicious race conditions
|
||||
verify_fileobj(f, expected_hash)
|
||||
f.seek(0)
|
||||
# Remove the target directory, if it exists from previous failed installation
|
||||
dst_dir = os.path.join(destination, os.path.basename(archive_path)[:-7])
|
||||
try:
|
||||
shutil.rmtree(dst_dir)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
# Extract the tar members while counting their size
|
||||
# If this is done as non-root, extractall() from https://github.com/python/cpython/blob/master/Lib/tarfile.py needs to be reimplemented instead
|
||||
tar = tarfile.open(fileobj=f)
|
||||
if observer:
|
||||
for tarinfo in tar:
|
||||
tar.extract(tarinfo, destination, numeric_owner=True)
|
||||
observer.units_done += tarinfo.size
|
||||
else:
|
||||
tar.extractall(destination, numeric_owner=True)
|
||||
# Remove the archive
|
||||
os.unlink(archive_path)
|
||||
|
||||
data = None
|
||||
|
||||
def load(force=False):
|
||||
# Download package manifest and signature and verify
|
||||
global data
|
||||
if not data or force:
|
||||
with requests.Session() as session:
|
||||
resource = session.get(config.ONLINE_REPO_URL, timeout=5)
|
||||
resource.raise_for_status()
|
||||
packages = resource.content
|
||||
resource = session.get(config.ONLINE_SIG_URL, timeout=5)
|
||||
resource.raise_for_status()
|
||||
packages_sig = resource.content
|
||||
# Raises cryptography.exceptions.InvalidSignature on verification failure
|
||||
get_public_key().verify(packages_sig, packages, ec.ECDSA(hashes.SHA512()))
|
||||
data = json.loads(packages.decode())
|
||||
|
||||
def get_entries(entry_type):
|
||||
load()
|
||||
return data[entry_type]
|
||||
|
||||
def get_entry(entry_type, name, exception):
|
||||
try:
|
||||
return get_entries(entry_type)[name]
|
||||
except KeyError as e:
|
||||
raise exception(name) from e
|
||||
|
||||
def get_images():
|
||||
return get_entries(TYPE_IMAGE)
|
||||
|
||||
def get_image(image_name):
|
||||
return get_entry(TYPE_IMAGE, image_name, ImageNotFoundError)
|
||||
|
||||
def get_apps():
|
||||
return get_entries(TYPE_APP)
|
||||
|
||||
def get_app(app_name):
|
||||
return get_entry(TYPE_APP, app_name, AppNotFoundError)
|
@ -1,114 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, utils
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
|
||||
from . import config
|
||||
from .exceptions import AppNotFoundError, ImageNotFoundError
|
||||
from .flock import locked
|
||||
|
||||
TYPE_APP = 'apps'
|
||||
TYPE_IMAGE = 'images'
|
||||
|
||||
class TarSizeCounter:
|
||||
def __init__(self):
|
||||
self.size = 0
|
||||
|
||||
def add_file(self, tarinfo):
|
||||
self.size += tarinfo.size
|
||||
return tarinfo
|
||||
|
||||
def sign_file(file_path):
|
||||
# Generate ECDSA HMAC SHA512 signature of a file using EC private key
|
||||
sha512 = hashes.SHA512()
|
||||
hasher = hashes.Hash(sha512, default_backend())
|
||||
with open(file_path, 'rb') as f:
|
||||
while True:
|
||||
data = f.read(64*1024)
|
||||
if not data:
|
||||
break
|
||||
hasher.update(data)
|
||||
with open(config.PUB_PRIVKEY_FILE, 'rb') as f:
|
||||
private_key = load_pem_private_key(f.read(), None, default_backend())
|
||||
return private_key.sign(hasher.finalize(), ec.ECDSA(utils.Prehashed(sha512)))
|
||||
|
||||
data = {TYPE_IMAGE: {}, TYPE_APP: {}}
|
||||
mtime = 0
|
||||
|
||||
def load():
|
||||
global data
|
||||
global mtime
|
||||
try:
|
||||
file_mtime = os.stat(config.PUB_REPO_FILE).st_mtime
|
||||
if mtime != file_mtime:
|
||||
with open(config.PUB_REPO_FILE) as f:
|
||||
data = json.load(f)
|
||||
mtime = file_mtime
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def save():
|
||||
global mtime
|
||||
# Open the repository file in read + write mode using exclusive lock
|
||||
with open(config.PUB_REPO_FILE, 'w') as f:
|
||||
json.dump(data, f, sort_keys=True, indent=4)
|
||||
mtime = os.stat(config.PUB_REPO_FILE).st_mtime
|
||||
# Cryptographically sign the repository file
|
||||
signature = sign_file(config.PUB_REPO_FILE)
|
||||
with open(config.PUB_SIG_FILE, 'wb') as f:
|
||||
f.write(signature)
|
||||
|
||||
@locked(config.PUB_LOCK_FILE)
|
||||
def get_entries(entry_type):
|
||||
load()
|
||||
return data[entry_type]
|
||||
|
||||
def get_entry(entry_type, name, exception):
|
||||
try:
|
||||
return get_entries(entry_type)[name]
|
||||
except KeyError as e:
|
||||
raise exception(name) from e
|
||||
|
||||
@locked(config.PUB_LOCK_FILE)
|
||||
def add_entry(entry_type, name, definition):
|
||||
load()
|
||||
data[entry_type][name] = definition
|
||||
save()
|
||||
|
||||
@locked(config.PUB_LOCK_FILE)
|
||||
def delete_entry(entry_type, name):
|
||||
load()
|
||||
try:
|
||||
del data[entry_type][name]
|
||||
save()
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def get_images():
|
||||
return get_entries(TYPE_IMAGE)
|
||||
|
||||
def get_image(image_name):
|
||||
return get_entry(TYPE_IMAGE, image_name, ImageNotFoundError)
|
||||
|
||||
def register_image(image_name, definition):
|
||||
add_entry(TYPE_IMAGE, image_name, definition)
|
||||
|
||||
def unregister_image(image_name):
|
||||
delete_entry(TYPE_IMAGE, image_name)
|
||||
|
||||
def get_apps():
|
||||
return get_entries(TYPE_APP)
|
||||
|
||||
def get_app(app_name):
|
||||
return get_entry(TYPE_APP, app_name, ImageNotFoundError)
|
||||
|
||||
def register_app(app_name, definition):
|
||||
add_entry(TYPE_APP, app_name, definition)
|
||||
|
||||
def unregister_app(app_name):
|
||||
delete_entry(TYPE_APP, app_name)
|
@ -1,52 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
LXC_CONTAINER_TEMPLATE = '''# Container name
|
||||
lxc.uts.name = {name}
|
||||
|
||||
# Network
|
||||
lxc.net.0.type = veth
|
||||
lxc.net.0.link = {interface}
|
||||
lxc.net.0.flags = up
|
||||
lxc.net.0.ipv4.address = {ip_address}/{ip_netmask}
|
||||
lxc.net.0.ipv4.gateway = {ip_gateway}
|
||||
|
||||
# Root filesystem
|
||||
lxc.rootfs.path = {rootfs}
|
||||
|
||||
# Mounts
|
||||
lxc.mount.entry = shm dev/shm tmpfs rw,nodev,noexec,nosuid,relatime,mode=1777,create=dir 0 0
|
||||
lxc.mount.entry = {resolv_conf} etc/resolv.conf none bind,ro,create=file 0 0
|
||||
lxc.mount.entry = {hosts} etc/hosts none bind,ro,create=file 0 0
|
||||
{mounts}
|
||||
|
||||
# Environment
|
||||
lxc.environment = PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
{env}
|
||||
|
||||
# Init
|
||||
lxc.init.uid = {uid}
|
||||
lxc.init.gid = {gid}
|
||||
lxc.init.cwd = {cwd}
|
||||
lxc.init.cmd = {cmd}
|
||||
|
||||
# Halt
|
||||
lxc.signal.halt = {halt}
|
||||
|
||||
# Log
|
||||
lxc.console.size = 1MB
|
||||
lxc.console.logfile = {log}
|
||||
|
||||
# ID map
|
||||
lxc.idmap = u 0 100000 65536
|
||||
lxc.idmap = g 0 100000 65536
|
||||
|
||||
# Hooks
|
||||
lxc.hook.version = 1
|
||||
lxc.hook.pre-start = /usr/bin/spoc-hook
|
||||
lxc.hook.post-stop = /usr/bin/spoc-hook
|
||||
|
||||
# Other
|
||||
lxc.arch = linux64
|
||||
lxc.include = /usr/share/lxc/config/common.conf
|
||||
lxc.include = /usr/share/lxc/config/userns.conf
|
||||
'''
|
Loading…
Reference in New Issue
Block a user