""" Platydock is a helper script that allows you to automatically create a Dockerfile for Platypush starting from a configuration file. """ import argparse from contextlib import contextmanager import inspect import logging import os import pathlib import re import subprocess import sys import textwrap from typing import IO, Generator, Iterable from platypush.builder import BaseBuilder from platypush.config import Config from platypush.utils.manifest import ( BaseImage, Dependencies, InstallContext, PackageManagers, ) logger = logging.getLogger() class DockerBuilder(BaseBuilder): """ Creates a Platypush Docker image from a configuration file. """ _pkg_manager_by_base_image = { BaseImage.ALPINE: PackageManagers.APK, BaseImage.DEBIAN: PackageManagers.APT, BaseImage.FEDORA: PackageManagers.DNF, BaseImage.UBUNTU: PackageManagers.APT, } _header = textwrap.dedent( """ # This Dockerfile was automatically generated by Platydock. # # You can build a Platypush image from it by running # `docker build -t platypush .` in the same folder as this file, # or copy it to the root a Platypush source folder to install the # checked out version instead of downloading it first. # # You can then run your new image through: # docker run --rm --name platypush \\ # -v /path/to/your/config/dir:/etc/platypush \\ # -v /path/to/your/workdir:/var/lib/platypush \\ # -p 8008:8008 \\ # platypush\n """ ) _footer = textwrap.dedent( """ # You can customize the name of your installation by passing # --device-id=... to the launched command. """ ) def __init__( self, *args, image: BaseImage, tag: str, print_only: bool = False, **kwargs ): kwargs['install_context'] = InstallContext.DOCKER super().__init__(*args, **kwargs) self.image = image self.tag = tag self.print_only = print_only # TODO @classmethod def get_name(cls): return "platydock" @classmethod def get_description(cls): return "Build a Platypush Docker image from a configuration file." @property def dockerfile_dir(self) -> str: """ Proxy property for the output Dockerfile directory. """ output = self.output parent = os.path.dirname(output) if os.path.isfile(output): return parent if os.path.isdir(output): return output logger.info('%s directory does not exist, creating it', output) pathlib.Path(output).mkdir(mode=0o750, parents=True, exist_ok=True) return output @property def dockerfile(self) -> str: """ Proxy property for the output Dockerfile. """ return os.path.join(self.dockerfile_dir, 'Dockerfile') @property def pkg_manager(self) -> PackageManagers: """ Proxy property for the package manager to use. """ return self._pkg_manager_by_base_image[self.image] def _read_base_dockerfile_lines(self) -> Generator[str, None, None]: """ :return: The lines of the base Dockerfile. """ import platypush base_file = os.path.join( str(pathlib.Path(inspect.getfile(platypush)).parent), 'install', 'docker', f'{self.image}.Dockerfile', ) with open(base_file, 'r') as f: for line in f: yield line.rstrip() @property def deps(self) -> Dependencies: return Dependencies.from_config( self.cfgfile, pkg_manager=self.pkg_manager, install_context=InstallContext.DOCKER, base_image=self.image, ) def _create_dockerfile_parser(self): """ Closure for a context-aware parser for the default Dockerfile. """ is_after_expose_cmd = False deps = self.deps ports = self._get_exposed_ports() def parser(): nonlocal is_after_expose_cmd for line in self._read_base_dockerfile_lines(): if re.match( r'RUN /install/platypush/install/scripts/[A-Za-z0-9_-]+/install.sh', line.strip(), ): yield self._generate_git_clone_command() elif line.startswith('RUN cd /install '): for new_line in deps.before: yield 'RUN ' + new_line for new_line in deps.to_pkg_install_commands(): yield 'RUN ' + new_line elif line == 'RUN rm -rf /install': for new_line in deps.to_pip_install_commands(): yield 'RUN ' + new_line for new_line in deps.after: yield 'RUN' + new_line elif line.startswith('EXPOSE ') and ports: if not is_after_expose_cmd: yield from [f'EXPOSE {port}' for port in ports] is_after_expose_cmd = True continue elif line.startswith('CMD'): yield from self._footer.split('\n') yield line if line.startswith('CMD') and self.device_id: yield f'\t--device-id {self.device_id} \\' return parser def build(self): """ Build a Dockerfile based on a configuration file. :return: The content of the generated Dockerfile. """ # Set the DOCKER_CTX environment variable so any downstream logic knows # that we're running in a Docker build context. os.environ['DOCKER_CTX'] = '1' self._generate_dockerfile() if self.print_only: return self._build_image() self._print_instructions( textwrap.dedent( f""" A Docker image has been built from the configuration file {self.cfgfile}. The Dockerfile is available under {self.dockerfile}. You can run the Docker image with the following command: docker run \\ --rm --name platypush \\ -v {os.path.dirname(self.cfgfile)}:/etc/platypush \\ -v /path/to/your/workdir:/var/lib/platypush \\ -p 8008:8008 \\ platypush """ ) ) def _build_image(self): """ Build a Platypush Docker image from the generated Dockerfile. """ logger.info('Building Docker image...') cmd = [ 'docker', 'build', '-f', self.dockerfile, '-t', self.tag, '.', ] subprocess.run(cmd, check=True) def _generate_dockerfile(self): """ Parses the configuration file and generates a Dockerfile based on it. """ @contextmanager def open_writer() -> Generator[IO, None, None]: # flake8: noqa f = sys.stdout if self.print_only else open(self.dockerfile, 'w') try: yield f finally: if f is not sys.stdout: f.close() if not self.print_only: logger.info('Parsing configuration file %s...', self.cfgfile) Config.init(self.cfgfile) if not self.print_only: logger.info('Generating Dockerfile %s...', self.dockerfile) parser = self._create_dockerfile_parser() with open_writer() as f: f.write(self._header + '\n') for line in parser(): f.write(line + '\n') def _generate_git_clone_command(self) -> str: """ Generates a git clone command in Dockerfile that checks out the repo and the right git reference, if the application sources aren't already available under /install. """ install_cmd = ' '.join(self.pkg_manager.value.install) uninstall_cmd = ' '.join(self.pkg_manager.value.uninstall) return textwrap.dedent( f""" RUN if [ ! -f "/install/setup.py" ]; then \\ echo "Platypush source not found under the current directory, downloading it" && \\ {install_cmd} git && \\ rm -rf /install && \\ git clone --recursive https://github.com/BlackLight/platypush.git /install && \\ cd /install && \\ git checkout {self.gitref} && \\ {uninstall_cmd} git; \\ fi """ ) @classmethod def _get_arg_parser(cls) -> argparse.ArgumentParser: parser = super()._get_arg_parser() parser.add_argument( '-i', '--image', dest='image', required=False, type=BaseImage, choices=list(BaseImage), default=BaseImage.ALPINE, help='Base image to use for the Dockerfile (default: alpine).', ) parser.add_argument( '-t', '--tag', dest='tag', required=False, type=str, default='platypush:latest', help='Tag name to be used for the built image ' '(default: "platypush:latest").', ) parser.add_argument( '--print', dest='print_only', action='store_true', help='Use this flag if you only want to print the Dockerfile to ' 'stdout instead of generating an image.', ) return parser @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(): """ Generates a Dockerfile based on the configuration file. """ DockerBuilder.from_cmdline(sys.argv[1:]).build() return 0 if __name__ == '__main__': sys.exit(main()) # vim:sw=4:ts=4:et: