platypush/platypush/platydock/__init__.py

355 lines
10 KiB
Python
Executable file

"""
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: