Refactored the interface of Platydock and manifest utils.

This commit is contained in:
Fabio Manganiello 2023-08-19 22:46:37 +02:00
parent a99ffea37c
commit 71c5291190
Signed by: blacklight
GPG Key ID: D90FBA7F76362774
2 changed files with 285 additions and 151 deletions

View File

@ -4,6 +4,7 @@ Dockerfile for Platypush starting from a configuration file.
""" """
import argparse import argparse
from enum import Enum
import inspect import inspect
import os import os
import pathlib import pathlib
@ -11,73 +12,108 @@ import sys
from typing import Iterable from typing import Iterable
from platypush.config import Config from platypush.config import Config
from platypush.utils.manifest import Dependencies from platypush.utils.manifest import Dependencies, InstallContext, PackageManagers
ERR_PREFIX = '\n\033[6;31;47mERROR\033[0;91m '
ERR_SUFFIX = '\033[0m'
def generate_dockerfile(cfgfile: str) -> str: class BaseImage(Enum):
""" """
Generate a Dockerfile based on a configuration file. Supported base images for Dockerfiles.
"""
ALPINE = 'alpine'
UBUNTU = 'ubuntu'
def __str__(self) -> str:
"""
Explicit __str__ override for argparse purposes.
"""
return self.value
class DockerfileGenerator:
"""
Generate a Dockerfile from on a configuration file.
:param cfgfile: Path to the configuration file. :param cfgfile: Path to the configuration file.
:return: The content of the generated Dockerfile. :param image: The base image to use.
""" """
Config.init(cfgfile)
new_file_lines = []
ports = _get_exposed_ports()
deps = Dependencies.from_config(cfgfile, pkg_manager='apk')
is_after_expose_cmd = False
base_file = os.path.join(
str(pathlib.Path(inspect.getfile(Config)).parent), 'docker', 'base.Dockerfile'
)
with open(base_file, 'r') as f: _pkg_manager_by_base_image = {
file_lines = [line.rstrip() for line in f.readlines()] BaseImage.ALPINE: PackageManagers.APK,
BaseImage.UBUNTU: PackageManagers.APT,
for line in file_lines:
if line.startswith('RUN cd /install '):
for new_line in deps.before:
new_file_lines.append('RUN ' + new_line)
for new_line in deps.to_pkg_install_commands(
pkg_manager='apk', skip_sudo=True
):
new_file_lines.append('RUN ' + new_line)
elif line == 'RUN rm -rf /install':
for new_line in deps.to_pip_install_commands():
new_file_lines.append('RUN ' + new_line)
for new_line in deps.after:
new_file_lines.append('RUN' + new_line)
elif line.startswith('EXPOSE ') and ports:
if not is_after_expose_cmd:
new_file_lines.extend([f'EXPOSE {port}' for port in ports])
is_after_expose_cmd = True
continue
new_file_lines.append(line)
return '\n'.join(new_file_lines)
def _get_exposed_ports() -> Iterable[int]:
"""
:return: The listen ports used by the backends enabled in the configuration
file.
"""
backends_config = Config.get_backends()
return {
int(port)
for port in (
backends_config.get('http', {}).get('port'),
backends_config.get('tcp', {}).get('port'),
)
if port
} }
def __init__(self, cfgfile: str, image: BaseImage) -> None:
self.cfgfile = os.path.abspath(os.path.expanduser(cfgfile))
self.image = image
def generate(self) -> str:
"""
Generate a Dockerfile based on a configuration file.
:param cfgfile: Path to the configuration file.
:return: The content of the generated Dockerfile.
"""
Config.init(self.cfgfile)
new_file_lines = []
ports = self._get_exposed_ports()
pkg_manager = self._pkg_manager_by_base_image[self.image]
deps = Dependencies.from_config(
self.cfgfile,
pkg_manager=pkg_manager,
install_context=InstallContext.DOCKER,
)
is_after_expose_cmd = False
base_file = os.path.join(
str(pathlib.Path(inspect.getfile(Config)).parent),
'docker',
f'{self.image}.Dockerfile',
)
with open(base_file, 'r') as f:
file_lines = [line.rstrip() for line in f.readlines()]
for line in file_lines:
if line.startswith('RUN cd /install '):
for new_line in deps.before:
new_file_lines.append('RUN ' + new_line)
for new_line in deps.to_pkg_install_commands():
new_file_lines.append('RUN ' + new_line)
elif line == 'RUN rm -rf /install':
for new_line in deps.to_pip_install_commands():
new_file_lines.append('RUN ' + new_line)
for new_line in deps.after:
new_file_lines.append('RUN' + new_line)
elif line.startswith('EXPOSE ') and ports:
if not is_after_expose_cmd:
new_file_lines.extend([f'EXPOSE {port}' for port in ports])
is_after_expose_cmd = True
continue
new_file_lines.append(line)
return '\n'.join(new_file_lines)
@staticmethod
def _get_exposed_ports() -> Iterable[int]:
"""
:return: The listen ports used by the backends enabled in the configuration
file.
"""
backends_config = Config.get_backends()
return {
int(port)
for port in (
backends_config.get('http', {}).get('port'),
backends_config.get('tcp', {}).get('port'),
)
if port
}
def main(): def main():
""" """
@ -87,22 +123,44 @@ def main():
prog='platydock', prog='platydock',
add_help=False, add_help=False,
description='Create a Platypush Dockerfile from a config.yaml.', description='Create a Platypush Dockerfile from a config.yaml.',
epilog='Use platydock <action> --help to get additional help.',
) )
parser.add_argument('-h', '--help', action='store_true', help='Show usage')
parser.add_argument( parser.add_argument(
'cfgfile', type=str, nargs=1, help='The path to the configuration file.' '-h', '--help', dest='show_usage', action='store_true', help='Show usage'
)
parser.add_argument(
'cfgfile', type=str, nargs='?', help='The path to the configuration file.'
)
parser.add_argument(
'--image',
'-i',
dest='image',
required=False,
type=BaseImage,
choices=list(BaseImage),
default=BaseImage.ALPINE,
help='Base image to use for the Dockerfile.',
) )
opts, _ = parser.parse_known_args(sys.argv[1:]) opts, _ = parser.parse_known_args(sys.argv[1:])
cfgfile = os.path.abspath(os.path.expanduser(opts.cfgfile[0])) if opts.show_usage:
dockerfile = generate_dockerfile(cfgfile) parser.print_help()
return 0
if not opts.cfgfile:
print(
f'Please specify a configuration file.\nRun {sys.argv[0]} --help to get the available options.',
file=sys.stderr,
)
return 1
dockerfile = DockerfileGenerator(opts.cfgfile, image=opts.image).generate()
print(dockerfile) print(dockerfile)
return 0
if __name__ == '__main__': if __name__ == '__main__':
main() sys.exit(main())
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View File

@ -1,5 +1,6 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass, field
import enum from enum import Enum
import importlib import importlib
import inspect import inspect
import json import json
@ -20,22 +21,102 @@ from typing import (
Type, Type,
Union, Union,
) )
from typing_extensions import override
import yaml import yaml
from platypush.message.event import Event from platypush.message.event import Event
supported_package_managers = {
'apk': ['apk', 'add', '--update', '--no-interactive', '--no-cache'],
'apt': ['apt', 'install', '-y'],
'pacman': ['pacman', '-S', '--noconfirm'],
}
_available_package_manager = None _available_package_manager = None
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ManifestType(enum.Enum): @dataclass
class PackageManager:
"""
Representation of a package manager.
"""
executable: str
""" The executable name. """
command: Iterable[str] = field(default_factory=tuple)
""" The command to execute, as a sequence of strings. """
class PackageManagers(Enum):
"""
Supported package managers.
"""
APK = PackageManager(
executable='apk',
command=('apk', 'add', '--update', '--no-interactive', '--no-cache'),
)
APT = PackageManager(
executable='apt',
command=('DEBIAN_FRONTEND=noninteractive', 'apt', 'install', '-y'),
)
PACMAN = PackageManager(
executable='pacman',
command=('pacman', '-S', '--noconfirm'),
)
@classmethod
def get_command(cls, name: str) -> Iterable[str]:
"""
:param name: The name of the package manager executable to get the
command for.
:return: The base command to execute, as a sequence of strings.
"""
pkg_manager = next(iter(pm for pm in cls if pm.value.executable == name), None)
if not pkg_manager:
raise ValueError(f'Unknown package manager: {name}')
return pkg_manager.value.command
@classmethod
def scan(cls) -> Optional["PackageManagers"]:
"""
Get the name of the available package manager on the system, if supported.
"""
# pylint: disable=global-statement
global _available_package_manager
if _available_package_manager:
return _available_package_manager
available_package_managers = [
pkg_manager
for pkg_manager in cls
if shutil.which(pkg_manager.value.executable)
]
if not available_package_managers:
logger.warning(
'\nYour OS does not provide any of the supported package managers.\n'
'You may have to install some optional dependencies manually.\n'
'Supported package managers: %s.\n',
', '.join([pm.value.executable for pm in cls]),
)
return None
_available_package_manager = available_package_managers[0]
return _available_package_manager
class InstallContext(Enum):
"""
Supported installation contexts.
"""
NONE = None
DOCKER = 'docker'
VENV = 'venv'
class ManifestType(Enum):
""" """
Manifest types. Manifest types.
""" """
@ -58,15 +139,32 @@ class Dependencies:
""" pip dependencies. """ """ pip dependencies. """
after: List[str] = field(default_factory=list) after: List[str] = field(default_factory=list)
""" Commands to execute after the component is installed. """ """ Commands to execute after the component is installed. """
pkg_manager: Optional[PackageManagers] = None
""" Override the default package manager detected on the system. """
install_context: InstallContext = InstallContext.NONE
@property
def _is_venv(self) -> bool:
"""
:return: True if the dependencies scanning logic is running either in a
virtual environment or in a virtual environment preparation
context.
"""
return (
self.install_context == InstallContext.VENV or sys.prefix != sys.base_prefix
)
@classmethod @classmethod
def from_config( def from_config(
cls, conf_file: Optional[str] = None, pkg_manager: Optional[str] = None cls,
conf_file: Optional[str] = None,
pkg_manager: Optional[PackageManagers] = None,
install_context: InstallContext = InstallContext.NONE,
) -> "Dependencies": ) -> "Dependencies":
""" """
Parse the required dependencies from a configuration file. Parse the required dependencies from a configuration file.
""" """
deps = cls() deps = cls(pkg_manager=pkg_manager, install_context=install_context)
for manifest in Manifests.by_config(conf_file, pkg_manager=pkg_manager): for manifest in Manifests.by_config(conf_file, pkg_manager=pkg_manager):
deps.before += manifest.install.before deps.before += manifest.install.before
@ -76,26 +174,19 @@ class Dependencies:
return deps return deps
def to_pkg_install_commands( def to_pkg_install_commands(self) -> Generator[str, None, None]:
self, pkg_manager: Optional[str] = None, skip_sudo: bool = False
) -> Generator[str, None, None]:
""" """
Generates the package manager commands required to install the given Generates the package manager commands required to install the given
dependencies on the system. dependencies on the system.
:param pkg_manager: Force package manager to use (default: looks for
the one available on the system).
:param skip_sudo: Skip sudo when installing packages (default: it will
look if the current user is root and use sudo otherwise).
""" """
wants_sudo = not skip_sudo and os.getuid() != 0 wants_sudo = self.install_context != InstallContext.DOCKER and os.getuid() != 0
pkg_manager = pkg_manager or get_available_package_manager() pkg_manager = self.pkg_manager or PackageManagers.scan()
if self.packages and pkg_manager: if self.packages and pkg_manager:
yield ' '.join( yield ' '.join(
[ [
*(['sudo'] if wants_sudo else []), *(['sudo'] if wants_sudo else []),
*supported_package_managers[pkg_manager], *pkg_manager.value.command,
*sorted(self.packages), *sorted(self.packages), # type: ignore
] ]
) )
@ -104,11 +195,14 @@ class Dependencies:
Generates the pip commands required to install the given dependencies on Generates the pip commands required to install the given dependencies on
the system. the system.
""" """
# Recent versions want an explicit --break-system-packages option when wants_break_system_packages = not (
# installing packages via pip outside of a virtual environment # Docker installations shouldn't require --break-system-packages in pip
wants_break_system_packages = ( self.install_context == InstallContext.DOCKER
sys.version_info > (3, 10) # --break-system-packages has been introduced in Python 3.10
and sys.prefix == sys.base_prefix # We're not in a venv or sys.version_info < (3, 11)
# If we're in a virtual environment then we don't need
# --break-system-packages
or self._is_venv
) )
if self.pip: if self.pip:
@ -118,24 +212,15 @@ class Dependencies:
+ ' '.join(sorted(self.pip)) + ' '.join(sorted(self.pip))
) )
def to_install_commands( def to_install_commands(self) -> Generator[str, None, None]:
self, pkg_manager: Optional[str] = None, skip_sudo: bool = False
) -> Generator[str, None, None]:
""" """
Generates the commands required to install the given dependencies on Generates the commands required to install the given dependencies on
this system. this system.
:param pkg_manager: Force package manager to use (default: looks for
the one available on the system).
:param skip_sudo: Skip sudo when installing packages (default: it will
look if the current user is root and use sudo otherwise).
""" """
for cmd in self.before: for cmd in self.before:
yield cmd yield cmd
for cmd in self.to_pkg_install_commands( for cmd in self.to_pkg_install_commands():
pkg_manager=pkg_manager, skip_sudo=skip_sudo
):
yield cmd yield cmd
for cmd in self.to_pip_install_commands(): for cmd in self.to_pip_install_commands():
@ -145,7 +230,7 @@ class Dependencies:
yield cmd yield cmd
class Manifest: class Manifest(ABC):
""" """
Base class for plugin/backend manifests. Base class for plugin/backend manifests.
""" """
@ -156,10 +241,10 @@ class Manifest:
description: Optional[str] = None, description: Optional[str] = None,
install: Optional[Dict[str, Iterable[str]]] = None, install: Optional[Dict[str, Iterable[str]]] = None,
events: Optional[Mapping] = None, events: Optional[Mapping] = None,
pkg_manager: Optional[str] = None, pkg_manager: Optional[PackageManagers] = None,
**_, **_,
): ):
self._pkg_manager = pkg_manager or get_available_package_manager() self._pkg_manager = pkg_manager or PackageManagers.scan()
self.description = description self.description = description
self.install = self._init_deps(install or {}) self.install = self._init_deps(install or {})
self.events = self._init_events(events or {}) self.events = self._init_events(events or {})
@ -167,6 +252,13 @@ class Manifest:
self.component_name = '.'.join(package.split('.')[2:]) self.component_name = '.'.join(package.split('.')[2:])
self.component = None self.component = None
@property
@abstractmethod
def manifest_type(self) -> ManifestType:
"""
:return: The type of the manifest.
"""
def _init_deps(self, install: Mapping[str, Iterable[str]]) -> Dependencies: def _init_deps(self, install: Mapping[str, Iterable[str]]) -> Dependencies:
deps = Dependencies() deps = Dependencies()
for key, items in install.items(): for key, items in install.items():
@ -176,7 +268,7 @@ class Manifest:
deps.before += items deps.before += items
elif key == 'after': elif key == 'after':
deps.after += items deps.after += items
elif key == self._pkg_manager: elif self._pkg_manager and key == self._pkg_manager.value.executable:
deps.packages.update(items) deps.packages.update(items)
return deps return deps
@ -201,7 +293,9 @@ class Manifest:
return ret return ret
@classmethod @classmethod
def from_file(cls, filename: str, pkg_manager: Optional[str] = None) -> "Manifest": def from_file(
cls, filename: str, pkg_manager: Optional[PackageManagers] = None
) -> "Manifest":
""" """
Parse a manifest filename into a ``Manifest`` class. Parse a manifest filename into a ``Manifest`` class.
""" """
@ -210,9 +304,21 @@ class Manifest:
assert 'type' in manifest, f'Manifest file {filename} has no type field' assert 'type' in manifest, f'Manifest file {filename} has no type field'
comp_type = ManifestType(manifest.pop('type')) comp_type = ManifestType(manifest.pop('type'))
manifest_class = _manifest_class_by_type[comp_type] manifest_class = cls.by_type(comp_type)
return manifest_class(**manifest, pkg_manager=pkg_manager) return manifest_class(**manifest, pkg_manager=pkg_manager)
@classmethod
def by_type(cls, manifest_type: ManifestType) -> Type["Manifest"]:
"""
:return: The manifest class corresponding to the given manifest type.
"""
if manifest_type == ManifestType.PLUGIN:
return PluginManifest
if manifest_type == ManifestType.BACKEND:
return BackendManifest
raise ValueError(f'Unknown manifest type: {manifest_type}')
def __repr__(self): def __repr__(self):
""" """
:return: A JSON serialized representation of the manifest. :return: A JSON serialized representation of the manifest.
@ -225,19 +331,23 @@ class Manifest:
'.'.join([evt_type.__module__, evt_type.__name__]): doc '.'.join([evt_type.__module__, evt_type.__name__]): doc
for evt_type, doc in self.events.items() for evt_type, doc in self.events.items()
}, },
'type': _manifest_type_by_class[self.__class__].value, 'type': self.manifest_type.value,
'package': self.package, 'package': self.package,
'component_name': self.component_name, 'component_name': self.component_name,
} }
) )
# pylint: disable=too-few-public-methods
class PluginManifest(Manifest): class PluginManifest(Manifest):
""" """
Plugin manifest. Plugin manifest.
""" """
@property
@override
def manifest_type(self) -> ManifestType:
return ManifestType.PLUGIN
# pylint: disable=too-few-public-methods # pylint: disable=too-few-public-methods
class BackendManifest(Manifest): class BackendManifest(Manifest):
@ -245,6 +355,11 @@ class BackendManifest(Manifest):
Backend manifest. Backend manifest.
""" """
@property
@override
def manifest_type(self) -> ManifestType:
return ManifestType.BACKEND
class Manifests: class Manifests:
""" """
@ -253,7 +368,7 @@ class Manifests:
@staticmethod @staticmethod
def by_base_class( def by_base_class(
base_class: Type, pkg_manager: Optional[str] = None base_class: Type, pkg_manager: Optional[PackageManagers] = None
) -> Generator[Manifest, None, None]: ) -> Generator[Manifest, None, None]:
""" """
Get all the manifest files declared under the base path of a given class Get all the manifest files declared under the base path of a given class
@ -267,7 +382,7 @@ class Manifests:
@staticmethod @staticmethod
def by_config( def by_config(
conf_file: Optional[str] = None, conf_file: Optional[str] = None,
pkg_manager: Optional[str] = None, pkg_manager: Optional[PackageManagers] = None,
) -> Generator[Manifest, None, None]: ) -> Generator[Manifest, None, None]:
""" """
Get all the manifest objects associated to the extensions declared in a Get all the manifest objects associated to the extensions declared in a
@ -294,42 +409,3 @@ class Manifests:
os.path.join(app_dir, 'plugins', *name.split('.'), 'manifest.yaml'), os.path.join(app_dir, 'plugins', *name.split('.'), 'manifest.yaml'),
pkg_manager=pkg_manager, pkg_manager=pkg_manager,
) )
def get_available_package_manager() -> Optional[str]:
"""
Get the name of the available package manager on the system, if supported.
"""
# pylint: disable=global-statement
global _available_package_manager
if _available_package_manager:
return _available_package_manager
available_package_managers = [
pkg_manager
for pkg_manager in supported_package_managers
if shutil.which(pkg_manager)
]
if not available_package_managers:
logger.warning(
'\nYour OS does not provide any of the supported package managers.\n'
'You may have to install some optional dependencies manually.\n'
'Supported package managers: %s.\n',
', '.join(supported_package_managers.keys()),
)
return None
_available_package_manager = available_package_managers[0]
return _available_package_manager
_manifest_class_by_type: Mapping[ManifestType, Type[Manifest]] = {
ManifestType.PLUGIN: PluginManifest,
ManifestType.BACKEND: BackendManifest,
}
_manifest_type_by_class: Mapping[Type[Manifest], ManifestType] = {
cls: t for t, cls in _manifest_class_by_type.items()
}