diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3930b8b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git/ +.idea/ + +dist/ +example/ + +Dockerfile +.dockerignore +.gitignore +.drone.yml \ No newline at end of file diff --git a/.drone.yml b/.drone.yml index ecd6889..d0bae99 100644 --- a/.drone.yml +++ b/.drone.yml @@ -3,6 +3,14 @@ kind: pipeline name: default steps: +- name: pytest + image: python:3.6-alpine3.13 + commands: + - apk add --no-cache g++ libffi-dev + - wget -O- https://raw.githubusercontent.com/python-poetry/poetry/master/install-poetry.py | python3 - + - /root/.local/bin/poetry install + - /root/.local/bin/poetry run pytest + - name: docker image: plugins/docker settings: diff --git a/.gitignore b/.gitignore index c18dd8d..03a44c5 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ __pycache__/ +htmlcov/ +.coverage \ No newline at end of file diff --git a/.idea/kiwi-scp.iml b/.idea/kiwi-scp.iml index 127e464..2f08fd2 100644 --- a/.idea/kiwi-scp.iml +++ b/.idea/kiwi-scp.iml @@ -4,6 +4,8 @@ + + diff --git a/.idea/runConfigurations/Test_Coverage.xml b/.idea/runConfigurations/Test_Coverage.xml new file mode 100644 index 0000000..89e6064 --- /dev/null +++ b/.idea/runConfigurations/Test_Coverage.xml @@ -0,0 +1,18 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Tests__Debuggable_.xml b/.idea/runConfigurations/Tests__Debuggable_.xml new file mode 100644 index 0000000..e648d65 --- /dev/null +++ b/.idea/runConfigurations/Tests__Debuggable_.xml @@ -0,0 +1,18 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/kiwi.xml b/.idea/runConfigurations/kiwi.xml new file mode 100644 index 0000000..127b64e --- /dev/null +++ b/.idea/runConfigurations/kiwi.xml @@ -0,0 +1,23 @@ + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index dd4db5d..5a33c84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,6 @@ RUN set -ex; \ COPY . /usr/src/kiwi_scp RUN set -ex; \ - pip3 --use-feature=in-tree-build install /usr/src/kiwi_scp + pip3 --no-cache-dir --use-feature=in-tree-build install /usr/src/kiwi_scp ENTRYPOINT ["kiwi"] diff --git a/README.md b/README.md index 2b4828a..009461c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ The simple tool for managing container servers ## Quick start - Learn to use `docker` with `docker-compose` -- Install kiwi-scp +- Install `kiwi-scp` - Look at [the example instance](./example) - Look at the output of `kiwi --help` - Start building your own instances @@ -18,24 +18,24 @@ The simple tool for managing container servers ## Installation -A convenience installer is available as [install.sh](./install.sh) in this directory. +A convenience installer is available as [install.sh](./dist/install.sh) in the `dist` directory. You can `curl | sh` it using the following one-liner. ```shell script -curl --proto '=https' --tlsv1.2 -sSf 'https://raw.githubusercontent.com/ldericher/kiwi-scp/master/install.sh' | sh +curl --proto '=https' --tlsv1.2 -sSf 'https://raw.githubusercontent.com/ldericher/kiwi-scp/master/dist/install.sh' | sh ``` The installer downloads the `kiwi` launcher script and installs it to a location of your choice. Please consider installing into a directory inside your `$PATH`. -Run in a root shell or use `sudo sh` instead for system-wide installation. +Run in a root shell or use `sudo sh` at the end instead for system-wide installation. -You should now be able to run `kiwi init --show` and see the default configuration file. -This downloads the latest version of the main kiwi-scp executable and sets it up for you. +You should then be able to run `kiwi list --show` and see the default configuration file. +This installs the latest version of the kiwi-scp package and sets it up for you. ### Adjusting environment for `kiwi` -The `kiwi` executable depends on [Python](https://www.python.org/) 3.6 (or later) and +The `kiwi` executable depends on [Python](https://www.python.org/) 3.6.1 (or later) and [less](http://www.greenwoodsoftware.com/less/) being in your `$PATH`. In some cases, notably when using a multi-version system such as @@ -44,8 +44,7 @@ at login time. In those cases, you can simply create a `.kiwi_profile` file in your home directory. It will be sourced every time you use the `kiwi` command. -For the aforementioned case where you installed `centos-release-scl` and `rh-python36`, your `~/.kiwi_profile` should -contain: +For the aforementioned case where you installed `centos-release-scl` and `rh-python36`, your `~/.kiwi_profile` should contain: ```shell script #!/bin/sh @@ -59,8 +58,7 @@ contain: ### Create a kiwi-scp instance Any directory is implicitly a valid kiwi-scp instance using the default configuration. -To prevent surprises however, you should run `kiwi init` in an empty directory and follow its directions to -create a `kiwi.yml` before using `kiwi` more. +To prevent surprises however, you should run `kiwi init` and follow its directions to create a `kiwi.yml` for your instance before using `kiwi` more. ### Concept @@ -69,11 +67,11 @@ A kiwi-scp instance is a directory containing a bunch of static configuration fi "Static" there as in "those don't change during normal service operation". These files could be anything from actual `.conf` files to entire html-web-roots. -Non-static, but persistent files are to be kept in a "service data directory", by default `/var/kiwi`. -In your `docker-compose.yml` files, you can refer to that directory as **${TARGETROOT}**. +Non-static, persistent files are to be kept in a "service data storage", by default the directory `/var/local/kiwi`. +In your `docker-compose.yml` files, you can refer to that directory as **${KIWI_INSTANCE}**. -Start the current directory as a kiwi-scp instance using `kiwi up`, or stop it using `kiwi down`. -This also creates kiwi's internal hub network, which you can use as **kiwi_hub** in your `docker-compose.yml` files. +Start the current kiwi-scp instance using `kiwi up`, or stop it using `kiwi down`. +This also manages kiwi's internal hub network, which you can use as **kiwi_hub** in your `docker-compose.yml` files. ### Projects @@ -88,7 +86,7 @@ Before enabling or starting, consider editing the new project's `docker-compose. Finally, enable it with `kiwi enable `. You can also create, enable or (analogously) disable multiple projects in a single command. -Each project will have its own place in the service data directory, which you can refer to as **${TARGETDIR}**. +Each project will have its own place in the service data directory, which you can refer to as **${KIWI_PROJECT}**. Finally, start a project using `kiwi up `. @@ -100,7 +98,7 @@ kiwi-scp extends the logical bounds of `docker-compose` to handling multiple pro #### The `kiwi_hub` -With kiwi-scp, you get the internal kiwi_hub network for free. +With kiwi-scp, you get the internal `kiwi_hub` network for free. It allows for network communication between services in different projects. Be aware, services only connected to the kiwi_hub can't use a port mapping! In most cases, you will want to use this: @@ -112,25 +110,23 @@ networks: ``` -#### The `CONFDIR` +#### The `KIWI_CONFIG` Sometimes, it's convenient to re-use configuration files across projects. -For this use case, create a directory named `conf` in a project. -Those will all be combined into a directory available as **${CONFDIR}** in your `docker-compose.yml` files. +For this use case, create a directory named `config` in your instance. +In your `docker-compose.yml` files, you can refer to that directory as **${KIWI_CONFIG}**. #### `kiwi.yml` options ##### `version` -Version of kiwi-scp to use for this instance. -Default: Latest version. +Version of kiwi-scp to use for this instance. -##### `runtime:storage` -Path of the service data directory, available as **${TARGETROOT}** in projects. -Default: `/var/kiwi` +Default: Version of [`master` branch](https://github.com/ldericher/kiwi-scp/tree/master). + +##### `shells` +Sequence of additionally preferable shell executables when entering service containers. -##### `runtime:shells` -List of additionally preferable shell executables when entering service containers. Default: `- /bin/bash` Example: @@ -141,17 +137,74 @@ runtime: - /bin/fish ``` -##### `runtime:env` -Associative array of custom variables available in projects' `docker-compose.yml` files. -Default: `null` +##### `projects` +Sequence of project definitions in this instance. + +###### Project definition +Defining a project in this instance. Any subdirectory with a `docker-compose.yml` should be considered a project. The directory name is equivalent to the project name. + +Format[^1]: Mapping using the keys `name` (required), `enabled` and `override_storage` Example: ```yaml -runtime: - env: - HELLO: "World" - FOO: "Bar" +- name: "hello_world" + enabled: true ``` -#### For everything else, look at `kiwi --help` -#### Happy kiwi-ing! +##### `environment` +Custom variables available in projects' `docker-compose.yml` files. + +Format[^1]: Mapping of `KEY: "value"` pairs +Example: + +```yaml +environment: + HELLO: "World" + FOO: "Bar" +``` + +##### `storage` +Configuration for the service data storage. + +Format: Mapping using the key `directory` +Example: + +```yaml +storage: + directory: "/var/local/kiwi" +``` + +###### `storage:directory` +Path to the local service data directory, the only currently supported service data storage. +Available as **${KIWI_INSTANCE}** in projects. + +Default: `"/var/local/kiwi"` + + +##### `network` +Configuration for the internal `kiwi_hub` network. + +Format: Mapping using the keys `name` and `cidr` +Example: + +```yaml +network: + name: "kiwi_hub" + cidr: "10.22.46.0/24" +``` + +###### `network:name` +Configuration for the internal `kiwi_hub` network. + +Default: `"kiwi_hub"` + +###### `network:cidr` +[CIDR notation](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing#IPv4_CIDR_blocks) for the subnet of the internal `kiwi_hub` network. + +Default: `"10.22.46.0/24"` + +**For everything else, look at `kiwi --help`** + +**Happy kiwi-ing!** + +[^1]: This is the officially correct format. For enabling varying conventions, there are multiple accepted formats. Start trying and check with `kiwi list --show` -- if it makes sense, it will likely just work. diff --git a/bump-version.sh b/bump-version.sh deleted file mode 100755 index 7c4b7b3..0000000 --- a/bump-version.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash - -this="$(readlink -f "${0}")" -this_dir="$(dirname "${this}")" - -git_branch="$(git rev-parse --abbrev-ref HEAD)" -git_tag="$(git describe --abbrev=0)" -version_str="${git_branch##*/}" - -echo "${version_str}" > "${this_dir}/kiwi_scp/data/etc/version_tag" -sed -ri "s/(version\s*:).*$/\1 '${version_str}'/" "${this_dir}/example/kiwi.yml" -sed -ri "s/(version\s*=\s*).*$/\1\"${version_str}\"/" "${this_dir}/pyproject.toml" diff --git a/dist/bump-version.sh b/dist/bump-version.sh new file mode 100755 index 0000000..6022e8c --- /dev/null +++ b/dist/bump-version.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +this="$(readlink -f "${0}")" +this_dir="$(dirname "${this}")" + +git_branch="$(git rev-parse --abbrev-ref HEAD)" +# git_tag="$(git describe --abbrev=0)" +version_str="${git_branch##*/}" +# version_str="0.2.0" + +sed -ri "s/(version\s*:).*$/\1 ${version_str}/" "${this_dir}/../example/kiwi.yml" +sed -ri "s/(version\s*=\s*).*$/\1\"${version_str}\"/" "${this_dir}/../pyproject.toml" +sed -ri "s/(version.*=\s*).*$/\1\"${version_str}\"/" "${this_dir}/../kiwi_scp/config.py" diff --git a/install.sh b/dist/install.sh similarity index 100% rename from install.sh rename to dist/install.sh diff --git a/kiwi b/dist/kiwi similarity index 90% rename from kiwi rename to dist/kiwi index 4038eb4..012fda1 100755 --- a/kiwi +++ b/dist/kiwi @@ -156,12 +156,20 @@ if [ "${run_kiwi_check}" = "yes" ]; then chmod 0777 "${CANARY_FILENAME}" fi -# check if pwd is a kiwi folder -if [ -f "./${KIWI_CONF_NAME}" ]; then - # determine needed kiwi-scp version - re_version_line='version\s*:\s*' - eval "$(grep -E "${re_version_line}" "./${KIWI_CONF_NAME}" | sed -r "s/${re_version_line}/KIWI_VERSION=/")" -fi +# check if pwd is a kiwi instance +path="$(pwd)" +while [ "${path}" != "" ]; do + if [ -e "${path}/${KIWI_CONF_NAME}" ]; then + # cd into kiwi instance + cd "${path}" || yes_no "Could not enter kiwi instance at '${path}'. Continue anyway?" || exit 1 + + # determine needed kiwi-scp version + re_version_line='version\s*:\s*' + eval "$(grep -E "${re_version_line}" "./${KIWI_CONF_NAME}" | sed -r "s/${re_version_line}/KIWI_VERSION=/")" + break; + fi + path="${path%/*}" +done # install if kiwi-scp not found if [ ! -x "$(kiwi_executable)" ]; then diff --git a/example/hello-world.project/conf/html/index.html b/example/config/html/index.html similarity index 100% rename from example/hello-world.project/conf/html/index.html rename to example/config/html/index.html diff --git a/example/hello-world.project/docker-compose.yml b/example/hello_world/docker-compose.yml similarity index 68% rename from example/hello-world.project/docker-compose.yml rename to example/hello_world/docker-compose.yml index 9a7ea1e..33c6f1a 100644 --- a/example/hello-world.project/docker-compose.yml +++ b/example/hello_world/docker-compose.yml @@ -10,20 +10,20 @@ networks: name: ${KIWI_HUB_NAME} services: - # simple loop producing (rather boring) logs greeter: + # simple loop producing (rather boring) logs image: alpine:latest command: sh -c 'LOOP=1; while :; do echo Hello World "$$LOOP"; LOOP=$$(($$LOOP + 1)); sleep 10; done' - # basic webserver listening on localhost:8080 web: + # basic webserver listening on localhost:8080 build: web restart: unless-stopped ports: - "8080:80" - # internal mariadb (mysql) instance with persistent storage db: + # internal mariadb (mysql) instance with persistent storage image: mariadb:10 restart: unless-stopped networks: @@ -31,10 +31,10 @@ services: environment: MYSQL_ROOT_PASSWORD: changeme volumes: - - "${TARGETDIR}/db:/var/lib/mysql" + - "${KIWI_PROJECT}/db:/var/lib/mysql" - # admin interface for databases adminer: + # admin interface for databases image: adminer:standalone restart: unless-stopped networks: @@ -45,11 +45,11 @@ services: ports: - "8081:8080" - # Another webserver just to show off the ${CONFDIR} variable another-web: + # Another webserver just to show off the ${KIWI_CONFIG} variable image: nginx:stable-alpine restart: unless-stopped ports: - "8082:80" volumes: - - "${CONFDIR}/html/index.html:/usr/share/nginx/html/index.html:ro" + - "${KIWI_CONFIG}/html/index.html:/usr/share/nginx/html/index.html:ro" diff --git a/example/hello-world.project/web/Dockerfile b/example/hello_world/web/Dockerfile similarity index 100% rename from example/hello-world.project/web/Dockerfile rename to example/hello_world/web/Dockerfile diff --git a/example/hello-world.project/web/index.html b/example/hello_world/web/index.html similarity index 100% rename from example/hello-world.project/web/index.html rename to example/hello_world/web/index.html diff --git a/example/kiwi.yml b/example/kiwi.yml index 1eb6293..a9f55fa 100644 --- a/example/kiwi.yml +++ b/example/kiwi.yml @@ -2,17 +2,17 @@ # kiwi-scp instance configuration # ################################### -version: '0.1.7' +version: 0.2.0 -runtime: - storage: /tmp/kiwi - shells: +shells: - /bin/bash - env: null -markers: - project: .project - disabled: .disabled +projects: + - name: hello_world + enabled: true + +storage: + directory: /var/local/kiwi network: name: kiwi_hub diff --git a/kiwi_scp/__init__.py b/kiwi_scp/__init__.py index ba8912f..e69de29 100644 --- a/kiwi_scp/__init__.py +++ b/kiwi_scp/__init__.py @@ -1,20 +0,0 @@ -# local -from .parser import Parser -from .runner import Runner - - -def verbosity(): - # ensure singleton is instantiated: runs subcommand setup routines - _ = Runner() - return Parser().get_args().verbosity - - -def run(): - # pass down - return Runner().run() - - -__all__ = [ - 'verbosity', - 'run' -] diff --git a/kiwi_scp/_constants.py b/kiwi_scp/_constants.py index cd12c09..31de4c9 100644 --- a/kiwi_scp/_constants.py +++ b/kiwi_scp/_constants.py @@ -1,13 +1,26 @@ -# system import os +############# +# REGEX PARTS + +# regex part for a number with no leading zeroes +_RE_NUMBER: str = r"(?:0|[1-9][0-9]*)" + +# regex for a semantic version string +RE_SEMVER = rf"^{_RE_NUMBER}(?:\.{_RE_NUMBER}(?:\.{_RE_NUMBER})?)?$" + +# regex for a variable name +RE_VARNAME = r"^[A-Za-z](?:[A-Za-z0-9\._-]*[A-Za-z0-9])$" + ############# # ENVIRONMENT # location of "kiwi_scp" module KIWI_ROOT = os.path.dirname(__file__) # default name of kiwi-scp file -KIWI_CONF_NAME = os.getenv('KIWI_CONF_NAME', "kiwi.yml") +KIWI_CONF_NAME = os.getenv("KIWI_CONF_NAME", "kiwi.yml") +# default name of compose files +COMPOSE_FILE_NAME = "docker-compose.yml" ############ # FILE NAMES @@ -15,19 +28,22 @@ KIWI_CONF_NAME = os.getenv('KIWI_CONF_NAME', "kiwi.yml") # text files inside kiwi-scp "src" directory HEADER_KIWI_CONF_NAME = f"{KIWI_ROOT}/data/etc/kiwi_header.yml" DEFAULT_KIWI_CONF_NAME = f"{KIWI_ROOT}/data/etc/kiwi_default.yml" -VERSION_TAG_NAME = f"{KIWI_ROOT}/data/etc/version_tag" DEFAULT_DOCKER_COMPOSE_NAME = f"{KIWI_ROOT}/data/etc/docker-compose_default.yml" -KIWI_HELP_TEXT_NAME = f"{KIWI_ROOT}/data/etc/kiwi_help.txt" -COMMAND_HELP_TEXT_NAME = f"{KIWI_ROOT}/data/etc/command_help.txt" -# special config directory in projects -CONF_DIRECTORY_NAME = 'conf' +# special config directory +CONFIG_DIRECTORY_NAME = "config" + # location for auxiliary Dockerfiles IMAGES_DIRECTORY_NAME = f"{KIWI_ROOT}/data/images" +# prohibited project names +RESERVED_PROJECT_NAMES = [ + CONFIG_DIRECTORY_NAME, +] + #################### # DOCKER IMAGE NAMES # name for auxiliary docker images -LOCAL_IMAGES_NAME = 'localhost/kiwi-scp/auxiliary' -DEFAULT_IMAGE_NAME = 'alpine:latest' +LOCAL_IMAGES_NAME = "localhost/kiwi-scp/auxiliary" +DEFAULT_IMAGE_NAME = "alpine:latest" diff --git a/kiwi_scp/commands/__init__.py b/kiwi_scp/commands/__init__.py new file mode 100644 index 0000000..ffeb421 --- /dev/null +++ b/kiwi_scp/commands/__init__.py @@ -0,0 +1,3 @@ +from .cli import KiwiCLI + +__all__ = ["KiwiCLI"] diff --git a/kiwi_scp/commands/cli.py b/kiwi_scp/commands/cli.py new file mode 100644 index 0000000..ed1ceed --- /dev/null +++ b/kiwi_scp/commands/cli.py @@ -0,0 +1,95 @@ +import importlib +import os +from gettext import gettext as _ +from typing import List, Optional + +import click + + +class MissingCMDObjectError(ValueError): + """raised if command object can't be found in its module""" + pass + + +class CMDObjectSubclassError(TypeError): + """raised if a command object is not inheriting click.Command""" + pass + + +class CMDUnregisteredError(ValueError): + """raised if commands have not been assigned to a command group""" + + unregistered: List[str] + + def __init__(self, unregistered): + self.unregistered = unregistered + + super().__init__(f"Some commands were not registered in a group above: {unregistered!r}") + + +class KiwiCLI(click.MultiCommand): + """Command Line Interface spread over multiple files in this directory""" + + def list_commands(self, ctx: click.Context) -> List[str]: + """list all the commands defined by cmd_*.py files in this directory""" + + return [ + filename[4:-3] + for filename in os.listdir(os.path.abspath(os.path.dirname(__file__))) + if filename.startswith("cmd_") and filename.endswith(".py") + ] + + def get_command(self, ctx: click.Context, cmd_name: str) -> Optional[click.Command]: + """import and return a specific command""" + + try: + cmd_module = importlib.import_module(f"kiwi_scp.commands.cmd_{cmd_name}") + + except ImportError: + return + + cmd_object_name = f"{cmd_name.capitalize()}Command" + + if cmd_object_name in dir(cmd_module): + cmd_object = getattr(cmd_module, cmd_object_name) + + if isinstance(cmd_object, click.Command): + return cmd_object + + else: + raise CMDObjectSubclassError() + + else: + raise MissingCMDObjectError() + + def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: + commands = { + "Operation": [ + "up", "down", "restart", "update", + ], + "Instance Management": [ + "init", "list", + ], + "Project and Service Management": [ + "new", "enable", "disable", "logs", "shell", "cmd", + ], + "Image Handling": [ + "build", "pull", "push", + ], + } + + # allow for 3 times the default spacing + cmd_names = set(self.list_commands(ctx)) + limit = formatter.width - 6 - max(len(cmd_name) for cmd_name in cmd_names) + + for purpose, cmd_list in commands.items(): + with formatter.section(_(f"Commands for {purpose}")): + formatter.write_dl([ + (cmd_name, self.get_command(ctx, cmd_name).get_short_help_str(limit)) + for cmd_name in cmd_list + ]) + + cmd_names -= set(cmd_list) + + if len(cmd_names) > 0: + raise CMDUnregisteredError(cmd_names) diff --git a/kiwi_scp/commands/cmd.py b/kiwi_scp/commands/cmd.py new file mode 100644 index 0000000..70d20e2 --- /dev/null +++ b/kiwi_scp/commands/cmd.py @@ -0,0 +1,184 @@ +import logging +import sys +from enum import Enum, auto +from typing import TypeVar, Iterable, Type, Optional, List + +import click + +from ..instance import Instance +from ..project import Project +from ..services import Services +from ..wstring import WParagraph, WAlignment + +_logger = logging.getLogger(__name__) + + +class KiwiCommandType(Enum): + INSTANCE = auto() + PROJECT = auto() + PROJECTS = auto() + SERVICES = auto() + + +T = TypeVar("T") + + +class KiwiCommandNotImplementedError(Exception): + pass + + +class KiwiCommand: + type: KiwiCommandType = KiwiCommandType.SERVICES + enabled_only: bool = False + + @staticmethod + def print_header(header: str) -> None: + click.secho(header, fg="green", bold=True) + + @staticmethod + def print_error(error: str) -> None: + click.secho(error, file=sys.stderr, fg="red", bold=True) + + @staticmethod + def print_list(content: Iterable[str]) -> None: + for item in content: + click.echo(click.style(" - ", fg="green") + click.style(item, fg="blue")) + + @staticmethod + def user_query(description: str, default: T, cast_to: Type[T] = str) -> T: + # prompt user as per argument + while True: + try: + prompt = \ + click.style(f"Enter {description} [", fg="green") + \ + click.style(default, fg="blue") + \ + click.style("] ", fg="green") + str_value = input(prompt).strip() + if str_value: + return cast_to(str_value) + else: + return default + + except EOFError: + click.echo("Input aborted.") + return default + + except Exception as e: + click.echo(f"Invalid input: {e}") + + @staticmethod + def danger_confirm(*prompt_lines: str, default: Optional[bool] = None) -> bool: + if default is True: + suffix = "[YES|no]" + default_answer = "yes" + + elif default is False: + suffix = "[yes|NO]" + default_answer = "no" + + else: + suffix = "[yes|no]" + default_answer = None + + dumb = WParagraph.from_strings( + click.style("WARNING", bold=True, underline=True, blink=True, fg="red"), + click.style("ここにゴミ", fg="cyan"), + click.style("を捨てないで下さい", fg="cyan"), + click.style("DO NOT DUMB HERE", fg="yellow"), + click.style("NO DUMB AREA", fg="yellow"), + ).align(WAlignment.CENTER).surround("!") + + prompt = WParagraph.from_strings(*prompt_lines).align(WAlignment.LEFT).emphasize(3) + + answer = input( + f"{dumb}\n\n" + f"{prompt}\n\n" + f"Are you sure you want to proceed? {suffix}: " + ).strip().lower() + + if not answer: + answer = default_answer + + while answer not in ["yes", "no"]: + answer = input("Please type 'yes' or 'no' explicitly: ").strip().lower() + + return answer == "yes" + + @classmethod + def run(cls, instance: Instance, project_names: List[str], service_names: List[str], **kwargs) -> None: + + _logger.debug(f"{instance.directory!r}: {project_names!r}, {service_names!r}") + + projects = instance.get_projects(project_names) + + if not projects: + # run for whole instance + _logger.debug(f"running for instance, kwargs={kwargs}") + cls.run_for_instance(instance, **kwargs) + + elif not service_names: + # run for entire project(s) + for project_name, project in projects.items(): + if project is None: + _logger.debug(f"running for new project {project_name}, kwargs={kwargs}") + cls.run_for_new_project(instance, project_name, **kwargs) + + else: + if cls.enabled_only and not project.config.enabled: + cls.print_error(f"Can't interact with disabled project {project_name}!") + return + + _logger.debug(f"running for project {project.name}, kwargs={kwargs}") + cls.run_for_project(instance, project, **kwargs) + + else: + # run for some services + project_name = list(projects)[0] + project = projects[project_name] + + if project is None: + cls.print_error(f"Project '{project_name}' not in kiwi-scp instance at '{instance.directory}'!") + + else: + if cls.enabled_only and not project.config.enabled: + cls.print_error(f"Can't interact with disabled project {project_name}!") + return + + _logger.debug(f"running for services {service_names} in project {project_name}, kwargs={kwargs}") + cls.run_for_services(instance, project, service_names, **kwargs) + + @classmethod + def run_for_instance(cls, instance: Instance, **kwargs) -> None: + for project in instance.projects: + if cls.enabled_only and not project.config.enabled: + cls.print_header(f"Skipping disabled project {project.name}") + continue + + cls.run_for_project(instance, project, **kwargs) + + @classmethod + def run_for_project(cls, instance: Instance, project: Project, **kwargs) -> None: + service_names = [service.name for service in project.services.content] + cls.run_for_services(instance, project, service_names, **kwargs) + + @classmethod + def run_for_new_project(cls, instance: Instance, project_name: str, **kwargs) -> None: + cls.print_error(f"Project '{project_name}' not in kiwi-scp instance at '{instance.directory}'!") + + @classmethod + def run_for_services(cls, instance: Instance, project: Project, service_names: List[str], **kwargs) -> None: + services = project.services.filter_existing(service_names) + + new_service_names = [ + service_name + for service_name + in service_names + if service_name not in list(services.names) + ] + + cls.run_for_filtered_services(instance, project, services, new_service_names, **kwargs) + + @classmethod + def run_for_filtered_services(cls, instance: Instance, project: Project, services: Services, + new_service_names: List[str], **kwargs) -> None: + raise KiwiCommandNotImplementedError() diff --git a/kiwi_scp/commands/cmd_build.py b/kiwi_scp/commands/cmd_build.py new file mode 100644 index 0000000..720632c --- /dev/null +++ b/kiwi_scp/commands/cmd_build.py @@ -0,0 +1,21 @@ +from typing import List + +from .cmd import KiwiCommandType, KiwiCommand +from .decorators import kiwi_command +from ..executable import COMPOSE_EXE +from ..instance import Instance +from ..project import Project + + +@kiwi_command( + short_help="Build docker images", +) +class BuildCommand(KiwiCommand): + """Build images for the whole instance, a project or service(s) inside a project""" + + type = KiwiCommandType.SERVICES + enabled_only = True + + @classmethod + def run_for_services(cls, instance: Instance, project: Project, service_names: List[str], **kwargs) -> None: + COMPOSE_EXE.run(["build", "--pull", *service_names], **project.process_kwargs) diff --git a/kiwi_scp/commands/cmd_cmd.py b/kiwi_scp/commands/cmd_cmd.py new file mode 100644 index 0000000..8a03692 --- /dev/null +++ b/kiwi_scp/commands/cmd_cmd.py @@ -0,0 +1,36 @@ +from typing import Tuple + +import click + +from .cmd import KiwiCommandType, KiwiCommand +from .decorators import kiwi_command +from ..executable import COMPOSE_EXE +from ..instance import Instance +from ..project import Project + + +@click.argument( + "compose_args", + metavar="[ARG]...", + nargs=-1, +) +@click.argument( + "compose_cmd", + metavar="COMMAND", +) +@kiwi_command( + short_help="Run docker-compose command", + # ignore arguments looking like options + # just pass everything down to docker-compose + context_settings={"ignore_unknown_options": True}, +) +class CmdCommand(KiwiCommand): + """Run raw docker-compose command in a project""" + + type = KiwiCommandType.PROJECT + enabled_only = True + + @classmethod + def run_for_project(cls, instance: Instance, project: Project, compose_cmd: str = None, + compose_args: Tuple[str] = None) -> None: + COMPOSE_EXE.run([compose_cmd, *compose_args], **project.process_kwargs) diff --git a/kiwi_scp/commands/cmd_disable.py b/kiwi_scp/commands/cmd_disable.py new file mode 100644 index 0000000..e7ee373 --- /dev/null +++ b/kiwi_scp/commands/cmd_disable.py @@ -0,0 +1,40 @@ +import click + +from .cmd import KiwiCommandType, KiwiCommand +from .decorators import kiwi_command +from .._constants import KIWI_CONF_NAME +from ..instance import Instance +from ..project import Project + + +@click.option( + "-f/-F", + "--force/--no-force", + help=f"skip confirmation", +) +@kiwi_command() +class DisableCommand(KiwiCommand): + """Disable project(s)""" + + type = KiwiCommandType.PROJECTS + + @classmethod + def run_for_instance(cls, instance: Instance, force: bool = None) -> None: + if not force: + if not KiwiCommand.danger_confirm("This will disable all projects in this instance."): + return + + super().run_for_instance(instance) + + @classmethod + def run_for_project(cls, instance: Instance, project: Project, **kwargs) -> None: + if not project.config.enabled: + KiwiCommand.print_error(f"Project {project.name} is already disabled!") + return + + project.config.enabled = False + KiwiCommand.print_header(f"Project {project.name} disabled") + + # write out the new kiwi.yml + with open(instance.directory.joinpath(KIWI_CONF_NAME), "w") as file: + instance.config.dump_kiwi_yml(file) diff --git a/kiwi_scp/commands/cmd_down.py b/kiwi_scp/commands/cmd_down.py new file mode 100644 index 0000000..2236e17 --- /dev/null +++ b/kiwi_scp/commands/cmd_down.py @@ -0,0 +1,57 @@ +from typing import List + +import click + +from .cmd import KiwiCommandType, KiwiCommand +from .decorators import kiwi_command +from ..executable import COMPOSE_EXE +from ..instance import Instance +from ..project import Project +from ..services import Services + + +@click.option( + "-f/-F", + "--force/--no-force", + help=f"skip confirmation", +) +@kiwi_command( + short_help="Bring down kiwi services", +) +class DownCommand(KiwiCommand): + """Bring down the whole instance, a project or service(s) inside a project""" + + type = KiwiCommandType.SERVICES + enabled_only = True + + @classmethod + def run_for_instance(cls, instance: Instance, force: bool = None) -> None: + if not force: + if not KiwiCommand.danger_confirm( + "This will bring down the entire instance.", + "", + "This may not be what you intended, because:", + " - Bringing down the instance stops ALL services in here", + ): + return + + super().run_for_instance(instance) + instance.remove_net() + + @classmethod + def run_for_project(cls, instance: Instance, project: Project, **kwargs) -> None: + COMPOSE_EXE.run(["down"], **project.process_kwargs) + + @classmethod + def run_for_filtered_services(cls, instance: Instance, project: Project, services: Services, + new_service_names: List[str], **kwargs) -> None: + if not services: + if not click.confirm( + "Did not find any of those services. \n" + f"Bring down the entire project {project.name} instead?", + default=True + ): + return + + COMPOSE_EXE.run(["stop", *services.names], **project.process_kwargs) + COMPOSE_EXE.run(["rm", "-f", *services.names], **project.process_kwargs) diff --git a/kiwi_scp/commands/cmd_enable.py b/kiwi_scp/commands/cmd_enable.py new file mode 100644 index 0000000..def3017 --- /dev/null +++ b/kiwi_scp/commands/cmd_enable.py @@ -0,0 +1,40 @@ +import click + +from .cmd import KiwiCommandType, KiwiCommand +from .decorators import kiwi_command +from .._constants import KIWI_CONF_NAME +from ..instance import Instance +from ..project import Project + + +@click.option( + "-f/-F", + "--force/--no-force", + help=f"skip confirmation", +) +@kiwi_command() +class EnableCommand(KiwiCommand): + """Enable project(s)""" + + type = KiwiCommandType.PROJECTS + + @classmethod + def run_for_instance(cls, instance: Instance, force: bool = None) -> None: + if not force: + if not KiwiCommand.danger_confirm("This will enable all projects in this instance."): + return + + super().run_for_instance(instance) + + @classmethod + def run_for_project(cls, instance: Instance, project: Project, **kwargs) -> None: + if project.config.enabled: + KiwiCommand.print_error(f"Project {project.name} is already enabled!") + return + + project.config.enabled = True + KiwiCommand.print_header(f"Project {project.name} enabled") + + # write out the new kiwi.yml + with open(instance.directory.joinpath(KIWI_CONF_NAME), "w") as file: + instance.config.dump_kiwi_yml(file) diff --git a/kiwi_scp/commands/cmd_init.py b/kiwi_scp/commands/cmd_init.py new file mode 100644 index 0000000..a5ae22d --- /dev/null +++ b/kiwi_scp/commands/cmd_init.py @@ -0,0 +1,71 @@ +import logging +import os +from ipaddress import IPv4Network +from pathlib import Path + +import click + +from .cmd import KiwiCommandType, KiwiCommand +from .decorators import kiwi_command +from .._constants import KIWI_CONF_NAME +from ..config import KiwiConfig +from ..instance import Instance + +_logger = logging.getLogger(__name__) + + +@click.option( + "-d", + "--directory", + help=f"initialize a kiwi-scp instance in another directory", + type=click.Path( + path_type=Path, + dir_okay=True, + writable=True, + ), +) +@click.option( + "-f/-F", + "--force/--no-force", + help=f"use default values even if {KIWI_CONF_NAME} is present", +) +@kiwi_command( + short_help="Initializes kiwi-scp", +) +class InitCommand(KiwiCommand): + """Initialize or reconfigure a kiwi-scp instance""" + + type = KiwiCommandType.INSTANCE + + @classmethod + def run_for_instance(cls, instance: Instance, directory: Path = None, force: bool = None) -> None: + if directory is not None: + instance.directory = directory + + current_config = KiwiConfig() if force else instance.config + + # check force switch + if force and os.path.isfile(KIWI_CONF_NAME): + _logger.warning(f"About to overwrite an existing '{KIWI_CONF_NAME}'!") + + # build new kiwi dict + kiwi_dict = current_config.kiwi_dict + kiwi_dict.update({ + "version": KiwiCommand.user_query("kiwi-scp version to use in this instance", current_config.version), + "storage": { + "directory": KiwiCommand.user_query("local directory for service data", + current_config.storage.directory, Path), + }, + "network": { + "name": KiwiCommand.user_query("name for local network hub", current_config.network.name), + "cidr": KiwiCommand.user_query("CIDRv4 block for local network hub", current_config.network.cidr, + IPv4Network), + }, + }) + + # ensure output directory exists + if not os.path.isdir(instance.directory): + os.mkdir(instance.directory) + + # write out the new kiwi.yml + instance.save_config(KiwiConfig.parse_obj(kiwi_dict)) diff --git a/kiwi_scp/commands/cmd_list.py b/kiwi_scp/commands/cmd_list.py new file mode 100644 index 0000000..8fc82a4 --- /dev/null +++ b/kiwi_scp/commands/cmd_list.py @@ -0,0 +1,59 @@ +from typing import List + +import click + +from .cmd import KiwiCommandType, KiwiCommand +from .decorators import kiwi_command +from ..instance import Instance +from ..project import Project + + +@click.option( + "-s/-S", + "--show/--no-show", + help=f"show actual config contents instead", +) +@kiwi_command( + short_help="Inspect a kiwi-scp instance", +) +class ListCommand(KiwiCommand): + """List projects in this instance, services inside a project or service(s) inside a project""" + + type = KiwiCommandType.SERVICES + + @classmethod + def run_for_instance(cls, instance: Instance, show: bool = None) -> None: + if show: + KiwiCommand.print_header(f"Showing config for kiwi-scp instance at '{instance.directory}'.") + click.echo_via_pager(instance.config.kiwi_yml) + + else: + KiwiCommand.print_header(f"Projects in kiwi-scp instance at '{instance.directory}':") + KiwiCommand.print_list( + project.name + (click.style(" (disabled)", fg="red") if not project.enabled else "") + for project in instance.config.projects + ) + + @classmethod + def run_for_project(cls, instance: Instance, project: Project, show: bool = None) -> None: + if show: + KiwiCommand.print_header(f"Showing config for all services in project '{project.name}'.") + click.echo_via_pager(str(project.services)) + + else: + KiwiCommand.print_header(f"Services in project '{project.name}':") + KiwiCommand.print_list(service.name for service in project.services.content) + + @classmethod + def run_for_services(cls, instance: Instance, project: Project, service_names: List[str], + show: bool = None) -> None: + services = project.services.filter_existing(service_names) + if show: + service_names = [service.name for service in services.content] + KiwiCommand.print_header( + f"Showing config for matching services '{', '.join(service_names)}' in project '{project.name}'.") + click.echo_via_pager(str(services)) + + else: + KiwiCommand.print_header(f"Matching services in project '{project.name}':") + KiwiCommand.print_list(service.name for service in services.content) diff --git a/kiwi_scp/commands/cmd_logs.py b/kiwi_scp/commands/cmd_logs.py new file mode 100644 index 0000000..d7669a1 --- /dev/null +++ b/kiwi_scp/commands/cmd_logs.py @@ -0,0 +1,44 @@ +from typing import List + +import click + +from .cmd import KiwiCommandType, KiwiCommand +from .decorators import kiwi_command +from ..executable import COMPOSE_EXE +from ..instance import Instance +from ..project import Project +from ..services import Services + + +@click.option( + "-f/-F", + "--follow/--no-follow", + help="output appended data as log grows", +) +@kiwi_command( + short_help="Show logs", +) +class LogsCommand(KiwiCommand): + """Show logs of a project or service(s) inside a project""" + + type = KiwiCommandType.SERVICES + enabled_only = True + + @classmethod + def run_for_filtered_services(cls, instance: Instance, project: Project, services: Services, + new_service_names: List[str], follow: bool = None) -> None: + # include timestamps + compose_cmd = ["logs", "-t"] + + # handle following the log output + if follow: + compose_cmd.extend(("-f", "--tail=10")) + + compose_cmd.extend(services.names) + + if follow: + COMPOSE_EXE.run(compose_cmd, **project.process_kwargs) + + else: + # output is static, use pager + COMPOSE_EXE.run_with_pager(compose_cmd, **project.process_kwargs) diff --git a/kiwi_scp/commands/cmd_new.py b/kiwi_scp/commands/cmd_new.py new file mode 100644 index 0000000..0ac1394 --- /dev/null +++ b/kiwi_scp/commands/cmd_new.py @@ -0,0 +1,41 @@ +import os +import shutil + +from .cmd import KiwiCommandType, KiwiCommand +from .decorators import kiwi_command +from .._constants import DEFAULT_DOCKER_COMPOSE_NAME, COMPOSE_FILE_NAME, RESERVED_PROJECT_NAMES +from ..config import ProjectConfig +from ..instance import Instance +from ..project import Project + + +@kiwi_command() +class NewCommand(KiwiCommand): + """Create new empty project(s) in this instance""" + + type = KiwiCommandType.PROJECTS + + @classmethod + def run_for_project(cls, instance: Instance, project: Project, **kwargs) -> None: + KiwiCommand.print_error(f"Project {project.name} already exists!") + + @classmethod + def run_for_new_project(cls, instance: Instance, project_name: str, **kwargs) -> None: + if project_name in RESERVED_PROJECT_NAMES: + KiwiCommand.print_error(f"Project name '{project_name}' is reserved!") + return + + try: + os.mkdir(project_name) + instance.config.projects.append(ProjectConfig( + name=project_name, + enabled=False, + )) + shutil.copy( + DEFAULT_DOCKER_COMPOSE_NAME, + instance.directory.joinpath(project_name).joinpath(COMPOSE_FILE_NAME) + ) + instance.save_config(instance.config) + + except FileExistsError: + KiwiCommand.print_error(f"Project directory {project_name} already exists!") diff --git a/kiwi_scp/commands/cmd_pull.py b/kiwi_scp/commands/cmd_pull.py new file mode 100644 index 0000000..b5f0517 --- /dev/null +++ b/kiwi_scp/commands/cmd_pull.py @@ -0,0 +1,33 @@ +from typing import List + +import click + +from .cmd import KiwiCommandType, KiwiCommand +from .decorators import kiwi_command +from ..executable import COMPOSE_EXE +from ..instance import Instance +from ..project import Project +from ..services import Services + + +@kiwi_command( + short_help="Pull docker images", +) +class PullCommand(KiwiCommand): + """Pull images for the whole instance, a project or service(s) inside a project""" + + type = KiwiCommandType.SERVICES + enabled_only = True + + @classmethod + def run_for_filtered_services(cls, instance: Instance, project: Project, services: Services, + new_service_names: List[str], **kwargs) -> None: + if not services: + if not click.confirm( + "Did not find any of those services. \n" + f"Pull images for the entire project {project.name} instead?", + default=True + ): + return + + COMPOSE_EXE.run(["pull", "--ignore-pull-failures", *services.names], **project.process_kwargs) diff --git a/kiwi_scp/commands/cmd_push.py b/kiwi_scp/commands/cmd_push.py new file mode 100644 index 0000000..999677b --- /dev/null +++ b/kiwi_scp/commands/cmd_push.py @@ -0,0 +1,33 @@ +from typing import List + +import click + +from .cmd import KiwiCommandType, KiwiCommand +from .decorators import kiwi_command +from ..executable import COMPOSE_EXE +from ..instance import Instance +from ..project import Project +from ..services import Services + + +@kiwi_command( + short_help="Push docker images", +) +class PushCommand(KiwiCommand): + """Push images for the whole instance, a project or service(s) inside a project""" + + type = KiwiCommandType.SERVICES + enabled_only = True + + @classmethod + def run_for_filtered_services(cls, instance: Instance, project: Project, services: Services, + new_service_names: List[str], **kwargs) -> None: + if not services: + if not click.confirm( + "Did not find any of those services. \n" + f"Push images for the entire project {project.name} instead?", + default=True + ): + return + + COMPOSE_EXE.run(["push", *services.names], **project.process_kwargs) diff --git a/kiwi_scp/commands/cmd_restart.py b/kiwi_scp/commands/cmd_restart.py new file mode 100644 index 0000000..fbd1cf3 --- /dev/null +++ b/kiwi_scp/commands/cmd_restart.py @@ -0,0 +1,48 @@ +from typing import List + +import click + +from .cmd import KiwiCommandType, KiwiCommand +from .decorators import kiwi_command +from ..executable import COMPOSE_EXE +from ..instance import Instance +from ..project import Project +from ..services import Services + + +@click.option( + "-f/-F", + "--force/--no-force", + help=f"skip confirmation", +) +@kiwi_command( + short_help="Restart kiwi services", +) +class RestartCommand(KiwiCommand): + """Restart the whole instance, a project or service(s) inside a project""" + + type = KiwiCommandType.SERVICES + enabled_only = True + + @classmethod + def run_for_instance(cls, instance: Instance, force: bool = None) -> None: + if not force: + if not KiwiCommand.danger_confirm( + "This will restart the entire instance.", + ): + return + + super().run_for_instance(instance) + + @classmethod + def run_for_filtered_services(cls, instance: Instance, project: Project, services: Services, + new_service_names: List[str], **kwargs) -> None: + if not services: + if not click.confirm( + "Did not find any of those services. \n" + f"Restart the entire project {project.name} instead?", + default=True + ): + return + + COMPOSE_EXE.run(["restart", *services.names], **project.process_kwargs) diff --git a/kiwi_scp/commands/cmd_shell.py b/kiwi_scp/commands/cmd_shell.py new file mode 100644 index 0000000..2d1ca23 --- /dev/null +++ b/kiwi_scp/commands/cmd_shell.py @@ -0,0 +1,73 @@ +import logging +from typing import List, Optional + +import click + +from .cmd import KiwiCommandType, KiwiCommand +from .decorators import kiwi_command +from ..executable import COMPOSE_EXE +from ..instance import Instance +from ..project import Project +from ..services import Services + +_logger = logging.getLogger(__name__) + + +@click.option( + "-s", "--shell", + help="shell to spawn", + type=str, +) +@click.option( + "-u", "--user", + help="container user to run shell", + type=str, +) +@kiwi_command( + short_help="Spawn shell", +) +class ShellCommand(KiwiCommand): + """Spawn shell inside a project's service""" + + type = KiwiCommandType.SERVICES + enabled_only = True + + @classmethod + def run_for_filtered_services(cls, instance: Instance, project: Project, services: Services, + new_service_names: List[str], shell: Optional[str] = None, + user: Optional[str] = None) -> None: + # shells from KiwiConfig + shells = [ + *(str(path) for path in instance.config.shells), + # as a last resort, fall back to "/bin/sh" and "sh" + "/bin/sh", "sh", + ] + + # add shell from argument + if shell is not None: + shells.insert(0, shell) + + user_args = ["-u", user] if user is not None else [] + + for service in services.content: + try: + use_shell = next(service.existing_executables(shells)) + _logger.debug(f"Using shell {use_shell!r}") + + except StopIteration: + if shell is not None: + use_shell = shell + _logger.warning( + "Could not find a working shell in this container. " + f"Launching provided shell {use_shell!r} nevertheless. This might fail!" + ) + + else: + _logger.warning( + f"Could not find any working shell among {shells!r} in this container. " + "Please suggest a shell using the '-s SHELL' command line option!" + ) + continue + + # spawn shell + COMPOSE_EXE.run(['exec', *user_args, service.name, use_shell], **project.process_kwargs) diff --git a/kiwi_scp/commands/cmd_up.py b/kiwi_scp/commands/cmd_up.py new file mode 100644 index 0000000..d1004ce --- /dev/null +++ b/kiwi_scp/commands/cmd_up.py @@ -0,0 +1,34 @@ +from typing import List + +import click + +from .cmd import KiwiCommandType, KiwiCommand +from .decorators import kiwi_command +from ..executable import COMPOSE_EXE +from ..instance import Instance +from ..project import Project +from ..services import Services + + +@kiwi_command(short_help="Bring up kiwi services") +class UpCommand(KiwiCommand): + """Bring up the whole instance, a project or service(s) inside a project""" + + type = KiwiCommandType.SERVICES + enabled_only = True + + @classmethod + def run_for_filtered_services(cls, instance: Instance, project: Project, services: Services, + new_service_names: List[str], **kwargs) -> None: + if not services: + if not click.confirm( + "Did not find any of those services. \n" + f"Bring up the entire project {project.name} instead?", + default=True + ): + return + + instance.create_net() + services.copy_configs() + + COMPOSE_EXE.run(["up", "-d", *services.names], **project.process_kwargs) diff --git a/kiwi_scp/commands/cmd_update.py b/kiwi_scp/commands/cmd_update.py new file mode 100644 index 0000000..630e8dc --- /dev/null +++ b/kiwi_scp/commands/cmd_update.py @@ -0,0 +1,65 @@ +from typing import List + +import click +from click import get_current_context + +from .cmd import KiwiCommandType, KiwiCommand +from .cmd_build import BuildCommand +from .cmd_down import DownCommand +from .cmd_pull import PullCommand +from .cmd_up import UpCommand +from .decorators import kiwi_command +from ..instance import Instance +from ..project import Project +from ..services import Services + + +@click.option( + "-f/-F", + "--force/--no-force", + help=f"skip confirmation", +) +@kiwi_command( + short_help="Update kiwi services", +) +class UpdateCommand(KiwiCommand): + """Update the whole instance, a project or service(s) inside a project""" + + type = KiwiCommandType.SERVICES + enabled_only = True + + @classmethod + def run_for_instance(cls, instance: Instance, force: bool = None) -> None: + if not force: + if not KiwiCommand.danger_confirm( + "This will update the entire instance at once.", + "", + "This may not be what you intended, because:", + " - Updates may take a long time", + " - Updates may break beloved functionality", + ): + return + + super().run_for_instance(instance) + + @classmethod + def run_for_filtered_services(cls, instance: Instance, project: Project, services: Services, + new_service_names: List[str], **kwargs) -> None: + if not services: + if not click.confirm( + "Did not find any of those services. \n" + f"Update the entire project {project.name} instead?", + default=True + ): + return + + ctx = get_current_context() + assert isinstance(BuildCommand, click.Command) + ctx.forward(BuildCommand) + assert isinstance(PullCommand, click.Command) + ctx.forward(PullCommand) + services.copy_configs() + assert isinstance(DownCommand, click.Command) + ctx.forward(DownCommand) + assert isinstance(UpCommand, click.Command) + ctx.forward(UpCommand) diff --git a/kiwi_scp/commands/decorators.py b/kiwi_scp/commands/decorators.py new file mode 100644 index 0000000..7b28fc9 --- /dev/null +++ b/kiwi_scp/commands/decorators.py @@ -0,0 +1,83 @@ +from typing import Callable, Type, Optional, Tuple + +import click + +from .cmd import KiwiCommandType, KiwiCommand +from ..instance import Instance + +_pass_instance = click.make_pass_decorator( + Instance, + ensure=True, +) + +_project_arg = click.argument( + "project_name", + metavar="PROJECT", + type=str, +) + +_projects_arg = click.argument( + "project_names", + metavar="[PROJECT]...", + nargs=-1, + type=str, +) + +_services_arg_p = click.argument( + "project_name", + metavar="[PROJECT]", + required=False, + type=str, +) + +_services_arg_s = click.argument( + "service_names", + metavar="[SERVICE]...", + nargs=-1, + type=str, +) + + +def kiwi_command( + **decorator_kwargs, +) -> Callable: + def decorator(command_cls: Type[KiwiCommand]) -> Callable: + + @click.command( + help=command_cls.__doc__, + **decorator_kwargs, + ) + @_pass_instance + def cmd(ctx: Instance, project_name: Optional[str] = None, project_names: Optional[Tuple[str]] = None, + service_names: Optional[Tuple[str]] = None, **kwargs) -> None: + if command_cls.type is KiwiCommandType.INSTANCE: + project_names = [] + + elif command_cls.type is KiwiCommandType.PROJECTS: + project_names = list(project_names) + + else: + if project_name is None: + project_names = [] + + else: + project_names = [project_name] + + if command_cls.type is KiwiCommandType.SERVICES: + service_names = list(service_names) + + command_cls.run(ctx, project_names, service_names, **kwargs) + + if command_cls.type is KiwiCommandType.PROJECT: + cmd = _project_arg(cmd) + + elif command_cls.type is KiwiCommandType.PROJECTS: + cmd = _projects_arg(cmd) + + elif command_cls.type is KiwiCommandType.SERVICES: + cmd = _services_arg_p(cmd) + cmd = _services_arg_s(cmd) + + return cmd + + return decorator diff --git a/kiwi_scp/config.py b/kiwi_scp/config.py index 00c0dfc..c60f81c 100644 --- a/kiwi_scp/config.py +++ b/kiwi_scp/config.py @@ -1,157 +1,402 @@ -# system -import copy -import logging -import os -import re -import yaml +import functools +from ipaddress import IPv4Network +from pathlib import Path +from typing import Optional, Dict, List, Any, TextIO, Tuple -# local -from ._constants import KIWI_CONF_NAME, HEADER_KIWI_CONF_NAME, DEFAULT_KIWI_CONF_NAME, VERSION_TAG_NAME +from pydantic import BaseModel, constr, root_validator, validator + +from ._constants import RE_SEMVER, RE_VARNAME, KIWI_CONF_NAME, RESERVED_PROJECT_NAMES +from .yaml import YAML -class Config: +class InvalidFormatError(ValueError): + """raised if format recognition unsuccessful""" + + cls: type + member: Optional[str] + data: str + + def __init__(self, cls, data, member = None): + self.cls = cls + self.data = data + + if member is not None: + self.member = member + super().__init__(f"Invalid {self.cls.__name__!r}.{self.member!r} Format: {self.data!r}") + + else: + super().__init__(f"Invalid {self.cls.__name__!r} Format: {self.data!r}") + + +class StorageConfig(BaseModel): + """a storage subsection""" + + directory: Path + + @property + def kiwi_dict(self) -> Dict[str, Any]: + """write this object as a dictionary of strings""" + + return {"directory": str(self.directory)} + + @root_validator(pre=True) + @classmethod + def unify_storage(cls, values) -> Dict[str, Any]: + """parse different storage notations""" + + if "directory" in values: + # default format + return values + + else: + # undefined format + raise InvalidFormatError(cls, str(values)) + + +class ProjectNameReservedError(ValueError): + """raised if trying to create a project with a reserved name""" + + name: str + + def __init__(self, name): + self.name = name + super().__init__(f"Project name {self.name!r} is reserved!") + + +class ProjectConfig(BaseModel): + """a project subsection""" + + name: constr(regex=RE_VARNAME) + enabled: bool = True + override_storage: Optional[StorageConfig] + + @property + def kiwi_dict(self) -> Dict[str, Any]: + """write this object as a dictionary of strings""" + + result = self.dict(exclude={"override_storage"}) + + if self.override_storage is not None: + result["override_storage"] = self.override_storage.kiwi_dict + + return result + + @validator("name") + @classmethod + def check_project(cls, value: str) -> str: + """check if project name is allowed""" + + if value in RESERVED_PROJECT_NAMES: + raise ProjectNameReservedError(value) + + return value + + @validator("override_storage", pre=True) + @classmethod + def unify_storage(cls, value) -> Dict[str, Any]: + """parse different storage notations""" + + if value is None or isinstance(value, dict): + return value + + elif isinstance(value, str): + return {"directory": value} + + elif isinstance(value, list) and len(value) == 1: + return {"directory": value[0]} + + else: + # undefined format + return {} + + @root_validator(pre=True) + @classmethod + def unify_project(cls, values) -> Dict[str, Any]: + """parse different project notations""" + + if "name" in values: + # default format + return values + + elif len(values) == 1: + # short format: + # - : + + name, enabled = list(values.items())[0] + return { + "name": name, + "enabled": True if enabled is None else enabled, + } + + else: + # undefined format + raise InvalidFormatError(ProjectConfig, values) + + +class NetworkConfig(BaseModel): + """a network subsection""" + + name: constr(to_lower=True, regex=RE_VARNAME) + cidr: IPv4Network + + @property + def kiwi_dict(self) -> Dict[str, Any]: + """write this object as a dictionary of strings""" + + return { + "name": self.name, + "cidr": str(self.cidr), + } + + +class MissingMemberError(ValueError): + """raised if class member is missing a definition""" + + cls: type + member: str + + def __init__(self, cls, member): + self.cls = cls + self.member = member + super().__init__(f"Member {self.cls.__name__!r}.{self.member!r} is required!") + + +class KiwiConfig(BaseModel): """represents a kiwi.yml""" - __yml_content = {} - __keys = { - 'version': "kiwi-scp version to use in this instance", + version: constr(regex=RE_SEMVER) = "0.2.0" - 'runtime:storage': "local directory for service data", - 'runtime:shells': "shell preference for working in service containers", - 'runtime:env': "common environment for compose yml", + shells: List[Path] = [ + Path("/bin/bash"), + ] - 'markers:project': "marker string for project directories", - 'markers:disabled': "marker string for disabled projects", + projects: List[ProjectConfig] = [] - 'network:name': "name for local network hub", - 'network:cidr': "CIDR block for local network hub", - } + environment: Dict[str, Optional[str]] = {} - def __key_resolve(self, key): - """ - Resolve nested dictionaries + storage: StorageConfig = StorageConfig( + directory="/var/local/kiwi", + ) - If __yml_content is {'a': {'b': {'c': "val"}}} and key is 'a:b:c', - this returns a single dict {'c': "val"} and the direct key 'c' - """ + network: NetworkConfig = NetworkConfig( + name="kiwi_hub", + cidr="10.22.46.0/24", + ) - # "a:b:c" => path = ['a', 'b'], key = 'c' - path = key.split(':') - path, key = path[:-1], path[-1] + @classmethod + @functools.lru_cache(maxsize=5) + def from_directory(cls, directory: Path) -> "KiwiConfig": + """parses an actual kiwi.yml from disk (cached)""" - # resolve path - container = self.__yml_content - for step in path: - container = container[step] - - return container, key - - def __getitem__(self, key): - """array-like read access to __yml_content""" - - container, key = self.__key_resolve(key) - return container[key] - - def __setitem__(self, key, value): - """array-like write access to __yml_content""" - - container, key = self.__key_resolve(key) - container[key] = value - - def __str__(self): - """dump into textual representation""" - - # dump yml content - yml_string = yaml.dump( - self.__yml_content, - default_flow_style=False, sort_keys=False - ).strip() - - # insert newline before every main key - yml_string = re.sub(r'^(\S)', r'\n\1', yml_string, flags=re.MULTILINE) - - # load header comment from file - with open(HEADER_KIWI_CONF_NAME, 'r') as stream: - yml_string = stream.read() + yml_string - - return yml_string - - def _update_from_file(self, filename): - """return a copy updated using a kiwi.yml file""" - - with open(filename, 'r') as stream: - try: - # create copy - result = Config() - result.__yml_content = copy.deepcopy(self.__yml_content) - - # read file - logging.debug(f"Reading '{filename}' into '{id(result.__yml_content)}'") - result.__yml_content.update(yaml.safe_load(stream)) - - return result - except yaml.YAMLError as exc: - logging.error(exc) - - def user_query(self, key): - """query user for new config value""" - - # prompt user as per argument try: - result = input(f"Enter {self.__keys[key]} [{self[key]}] ").strip() - except EOFError: - print() - result = None + with open(directory.joinpath(KIWI_CONF_NAME)) as kc: + return cls.parse_obj(YAML().load(kc)) - # store result if present - if result: - self[key] = result - - def save(self): - """save current yml representation in current directory's kiwi.yml""" - - with open(KIWI_CONF_NAME, 'w') as stream: - stream.write(str(self)) - stream.write('\n') - - -class DefaultConfig(Config): - """Singleton: The default kiwi.yml file""" - - __instance = None + except FileNotFoundError: + # return the defaults if no kiwi.yml found + return cls.from_default() @classmethod - def get(cls): - if cls.__instance is None: - # create singleton - cls.__instance = cls()._update_from_file(DEFAULT_KIWI_CONF_NAME) + @functools.lru_cache(maxsize=1) + def from_default(cls) -> "KiwiConfig": + """returns the default config (cached)""" - # add version data from separate file (keeps default config cleaner) - with open(VERSION_TAG_NAME, 'r') as stream: - cls.__instance['version'] = stream.read().strip() + return cls() - # return singleton - return cls.__instance + def get_project_config(self, name: str) -> Optional[ProjectConfig]: + """returns the config of a project with a given name""" + for project in self.projects: + if project.name == name: + return project -class LoadedConfig(Config): - """Singleton collection: kiwi.yml files by path""" + @property + def kiwi_dict(self) -> Dict[str, Any]: + """write this object as a dictionary of strings""" - __instances = {} + result = { + "version": self.version, + "shells": [str(shell) for shell in self.shells], + } + if self.projects: + result["projects"] = [ + project.kiwi_dict + for project in self.projects + ] + + if self.environment: + result["environment"] = self.environment + + result["storage"] = self.storage.kiwi_dict + + result["network"] = self.network.kiwi_dict + + return result + + def dump_kiwi_yml(self, stream: TextIO = None) -> Optional[str]: + """dump a kiwi.yml file""" + + return YAML().dump_kiwi_yml(self.kiwi_dict, stream=stream) + + @property + def kiwi_yml(self) -> str: + """get a kiwi.yml dump as a string""" + + return self.dump_kiwi_yml() + + @validator("shells", pre=True) @classmethod - def get(cls, directory='.'): - if directory not in LoadedConfig.__instances: - # create singleton for new path - result = DefaultConfig.get() + def unify_shells(cls, value) -> List[str]: + """parse different shells notations""" - # update with that dir's kiwi.yml + if value is None: + return [] + + elif isinstance(value, list): + return value + + elif isinstance(value, dict): + return list(value) + + else: + # any other format (try to coerce to str first) try: - result = result._update_from_file(os.path.join(directory, KIWI_CONF_NAME)) - except FileNotFoundError: - logging.info(f"No '{KIWI_CONF_NAME}' found at '{directory}'. Using defaults.") + return [str(value)] - LoadedConfig.__instances[directory] = result + except Exception: + # undefined format + raise InvalidFormatError(KiwiConfig, value, "shells") - # return singleton - return LoadedConfig.__instances[directory] + @validator("projects", pre=True) + @classmethod + def unify_projects(cls, value) -> List[Dict[str, str]]: + """parse different projects notations""" + + if value is None: + # empty projects list + return [] + + elif isinstance(value, list): + # handle projects list + + result = [] + for entry in value: + # ignore empties + if entry is not None: + if isinstance(entry, dict): + # handle single project dict + result.append(entry) + + else: + try: + # handle single project name + result.append({"name": str(entry)}) + + except Exception: + # undefined format + raise InvalidFormatError(KiwiConfig, value, "projects") + + return result + + elif isinstance(value, dict): + # handle single project dict + return [value] + + else: + # any other format (try to coerce to str first) + try: + # handle as a single project name + return [{"name": str(value)}] + + except Exception: + # undefined format + raise InvalidFormatError(KiwiConfig, value, "projects") + + @validator("environment", pre=True) + @classmethod + def unify_environment(cls, value) -> Dict[str, Optional[str]]: + """parse different environment notations""" + + def parse_str(var_val: Any) -> Tuple[str, Optional[str]]: + """parse a "=" string""" + + try: + idx = str(var_val).find("=") + except Exception: + # undefined format + raise InvalidFormatError(KiwiConfig, value, "environment") + + if idx == -1: + # don't split, just define the variable + return var_val, None + else: + # split string, set variable to value + return var_val[:idx], var_val[idx + 1:] + + if value is None: + # empty environment + return {} + + elif isinstance(value, dict): + # native dict format + return value + + elif isinstance(value, list): + # list format (multiple strings) + + result: Dict[str, Optional[str]] = {} + for item in value: + key, value = parse_str(item) + result[key] = value + + return result + + else: + # any other format (try to coerce to str first) + # string format (single variable): + # "=" + key, value = parse_str(value) + return {key: value} + + @validator("storage", pre=True) + @classmethod + def unify_storage(cls, value) -> Dict[str, Any]: + """parse different storage notations""" + + if value is None: + # empty storage + raise MissingMemberError(KiwiConfig, "storage") + + elif isinstance(value, dict): + # native dict format + return value + + elif isinstance(value, str): + # just the directory string + return {"directory": value} + + elif isinstance(value, list) and len(value) == 1 and isinstance(value[0], str): + # directory string as a single-item list + return {"directory": value[0]} + + else: + # undefined format + return {} + + @validator("network", pre=True) + @classmethod + def unify_network(cls, value) -> Dict[str, Any]: + """parse different network notations""" + + if value is None: + # empty network + raise MissingMemberError(KiwiConfig, "network") + + elif isinstance(value, dict): + # native dict format + return value + + else: + # undefined format + raise InvalidFormatError(KiwiConfig, value, "network") diff --git a/kiwi_scp/data/etc/command_help.txt b/kiwi_scp/data/etc/command_help.txt deleted file mode 100644 index 33c0376..0000000 --- a/kiwi_scp/data/etc/command_help.txt +++ /dev/null @@ -1,22 +0,0 @@ -Commands for Operation: - up Bring up the whole instance, a project or service(s) inside a project - down Bring down the whole instance, a project or service(s) inside a project - update Update the whole instance, a project or service(s) inside a project - restart Restart the whole instance, a project or service(s) inside a project - -Commands for Instance Management: - init Initialize or reconfigure kiwi-scp instance - show Show projects in this instance, services inside a project or service(s) inside a project - cmd Run raw docker-compose command in a project - -Commands for Project and Service Management: - new Create new empty project(s) in this instance - enable Enable project(s) in this instance - disable Disable project(s) in this instance - logs Show logs of a project or service(s) inside a project - shell Spawn shell inside a service inside a project - -Commands for Image Handling: - build Build images for the whole instance, a project or service(s) inside a project - pull Pull images for the whole instance, a project or service(s) inside a project - push Push images for the whole instance, a project or service(s) inside a project \ No newline at end of file diff --git a/kiwi_scp/data/etc/docker-compose_default.yml b/kiwi_scp/data/etc/docker-compose_default.yml index d3cbd6d..cc6476a 100644 --- a/kiwi_scp/data/etc/docker-compose_default.yml +++ b/kiwi_scp/data/etc/docker-compose_default.yml @@ -10,6 +10,10 @@ networks: name: ${KIWI_HUB_NAME} services: + ###################### + # START EDITING HERE # + ###################### + # an example service something: # uses an image diff --git a/kiwi_scp/data/etc/kiwi_default.yml b/kiwi_scp/data/etc/kiwi_default.yml deleted file mode 100644 index 1404379..0000000 --- a/kiwi_scp/data/etc/kiwi_default.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: -runtime: - storage: /var/kiwi - shells: - - /bin/bash - env: null -markers: - project: .project - disabled: .disabled -network: - name: kiwi_hub - cidr: 10.22.46.0/24 diff --git a/kiwi_scp/data/etc/kiwi_help.txt b/kiwi_scp/data/etc/kiwi_help.txt deleted file mode 100644 index 08d0224..0000000 --- a/kiwi_scp/data/etc/kiwi_help.txt +++ /dev/null @@ -1,9 +0,0 @@ -kiwi is the simple tool for managing container servers. - -Features: - - Group services into projects using their own docker-compose.yml - - Bind to the local file system by using ${TARGETDIR} as volume in docker-compose.yml - - Add instance-global config files by using ${CONFDIR} as volume in docker-compose.yml - - Add instance-global custom values inside docker-compose.yml using config:runtime:env - - Build service-specific, private docker images from Dockerfiles - - Check full instances into any version control system diff --git a/kiwi_scp/data/etc/version_tag b/kiwi_scp/data/etc/version_tag deleted file mode 100644 index 1180819..0000000 --- a/kiwi_scp/data/etc/version_tag +++ /dev/null @@ -1 +0,0 @@ -0.1.7 diff --git a/kiwi_scp/executable.py b/kiwi_scp/executable.py index 1c054f5..184e41e 100644 --- a/kiwi_scp/executable.py +++ b/kiwi_scp/executable.py @@ -1,74 +1,64 @@ -# system +import functools import logging import os import subprocess +from pathlib import Path +from typing import Optional, List + +import attr + +_logger = logging.getLogger(__name__) -def _is_executable(filename): - if filename is None: - return False - - return os.path.isfile(filename) and os.access(filename, os.X_OK) - - -def _find_exe_file(exe_name): - for path in os.environ['PATH'].split(os.pathsep): - exe_file = os.path.join(path, exe_name) - if _is_executable(exe_file): - return exe_file - - raise FileNotFoundError(f"Executable '{exe_name}' not found in $PATH!") - - +@attr.s class Executable: - class __Executable: - __exe_path = None + exe_name: str = attr.ib() - def __init__(self, exe_name): - self.__exe_path = _find_exe_file(exe_name) + @staticmethod + @functools.lru_cache(maxsize=None) + def __find_exe_file(exe_name: str) -> Optional[Path]: + for path in os.environ["PATH"].split(os.pathsep): + exe_file = Path(path).joinpath(exe_name) + if os.path.isfile(exe_file) and os.access(exe_file, os.X_OK): + return exe_file - def __build_cmd(self, args, kwargs): - cmd = [self.__exe_path, *args] + raise FileNotFoundError(f"Executable '{exe_name}' not found in $PATH!") - logging.debug(f"Executable cmd{cmd}, kwargs{kwargs}") - return cmd + @property + def exe_file(self) -> Optional[Path]: + return self.__find_exe_file(self.exe_name) - def run(self, process_args, **kwargs): - return subprocess.run( - self.__build_cmd(process_args, kwargs), - **kwargs - ) + def __build_cmd(self, args, kwargs) -> List: + cmd = [self.exe_file, *args] - def Popen(self, process_args, **kwargs): - return subprocess.Popen( - self.__build_cmd(process_args, kwargs), - **kwargs - ) + _logger.debug(f"Executable cmd{cmd}, kwargs{kwargs}") + return cmd - def run_less(self, process_args, **kwargs): - kwargs['stdout'] = subprocess.PIPE - kwargs['stderr'] = subprocess.DEVNULL + def run(self, process_args, **kwargs) -> Optional[subprocess.CompletedProcess]: + return subprocess.run( + self.__build_cmd(process_args, kwargs), + **kwargs + ) - process = self.Popen( - process_args, - **kwargs - ) + def Popen(self, process_args, **kwargs) -> subprocess.Popen: + return subprocess.Popen( + self.__build_cmd(process_args, kwargs), + **kwargs + ) - less_process = Executable('less').run([ - '-R', '+G' + def run_with_pager(self, process_args, **kwargs) -> Optional[subprocess.CompletedProcess]: + kwargs["stdout"] = subprocess.PIPE + kwargs["stderr"] = subprocess.DEVNULL + + with self.Popen(process_args, **kwargs) as process: + less_process = Executable("less").run([ + "-R", "+G" ], stdin=process.stdout) process.communicate() - return less_process - __exe_name = None - __instances = {} + return less_process - def __init__(self, exe_name): - self.__exe_name = exe_name - if exe_name not in Executable.__instances: - Executable.__instances[exe_name] = Executable.__Executable(exe_name) - - def __getattr__(self, item): - return getattr(self.__instances[self.__exe_name], item) +DOCKER_EXE = Executable("docker") +COMPOSE_EXE = Executable("docker-compose") diff --git a/kiwi_scp/instance.py b/kiwi_scp/instance.py new file mode 100644 index 0000000..c40e1f7 --- /dev/null +++ b/kiwi_scp/instance.py @@ -0,0 +1,108 @@ +import logging +import subprocess +from pathlib import Path +from typing import Generator, Dict, Sequence + +import attr + +from ._constants import KIWI_CONF_NAME, CONFIG_DIRECTORY_NAME +from .config import KiwiConfig +from .executable import DOCKER_EXE +from .project import Project + +_logger = logging.getLogger(__name__) + + +@attr.s +class Instance: + directory: Path = attr.ib(default=Path('.')) + + @property + def config(self) -> KiwiConfig: + """shorthand: get the current configuration""" + + return KiwiConfig.from_directory(self.directory) + + def save_config(self, config: KiwiConfig) -> None: + with open(self.directory.joinpath(KIWI_CONF_NAME), "w") as file: + config.dump_kiwi_yml(file) + + @property + def config_directory(self): + return self.directory.joinpath(CONFIG_DIRECTORY_NAME) + + @property + def storage_config_directory(self): + return self.config.storage.directory.joinpath(CONFIG_DIRECTORY_NAME) + + @staticmethod + def __find_net(net_name): + ps = DOCKER_EXE.run([ + "network", "ls", "--filter", f"name={net_name}", "--format", "{{.Name}}" + ], stdout=subprocess.PIPE) + + net_found = str(ps.stdout, 'utf-8').strip() + + return net_found == net_name + + def create_net(self): + net_name = self.config.network.name + net_cidr = str(self.config.network.cidr) + + if self.__find_net(net_name): + _logger.info(f"Network '{net_name}' already exists") + return + + try: + DOCKER_EXE.run([ + "network", "create", + "--driver", "bridge", + "--internal", + "--subnet", net_cidr, + net_name + ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + _logger.info(f"Network '{net_name}' created") + + except subprocess.CalledProcessError: + _logger.error(f"Error creating network '{net_name}'") + + def remove_net(self): + net_name = self.config.network.name + + if not self.__find_net(net_name): + _logger.info(f"Network '{net_name}' does not exist") + return + + try: + DOCKER_EXE.run([ + "network", "rm", + net_name + ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + _logger.info(f"Network '{net_name}' removed") + + except subprocess.CalledProcessError: + _logger.error(f"Error removing network '{net_name}'") + + @property + def projects(self) -> Generator[Project, None, None]: + for project in self.config.projects: + yield Project( + directory=self.directory.joinpath(project.name), + parent_instance=self, + ) + + def get_projects(self, project_names: Sequence[str]) -> Dict[str, Project]: + existing_projects = { + project.name: project + for project in self.projects + if project.name in project_names + } + nonexistent_projects = { + name: None + for name in project_names + if name not in existing_projects + } + return { + **existing_projects, + **nonexistent_projects + } diff --git a/kiwi_scp/misc.py b/kiwi_scp/misc.py deleted file mode 100644 index ed91da9..0000000 --- a/kiwi_scp/misc.py +++ /dev/null @@ -1,37 +0,0 @@ -def _surround(string, bang): - midlane = f"{bang * 3} {string} {bang * 3}" - sidelane = bang * len(midlane) - - return f"{sidelane}\n{midlane}\n{sidelane}" - - -def _emphasize(lines): - if isinstance(lines, list): - return '\n'.join([_emphasize(line) for line in lines]) - elif lines: - return f">>> {lines} <<<" - else: - return lines - - -def are_you_sure(prompt, default="no"): - if default.lower() == 'yes': - suffix = "[YES|no]" - else: - suffix = "[yes|NO]" - - answer = input( - f"{_surround('MUST HAVE CAREFULING IN PROCESS', '!')}\n" - f"\n" - f"{_emphasize(prompt)}\n" - f"\n" - f"Are you sure you want to proceed? {suffix} " - ).strip().lower() - - if answer == '': - answer = default - - while answer not in ['yes', 'no']: - answer = input("Please type 'yes' or 'no' explicitly: ").strip().lower() - - return answer == 'yes' diff --git a/kiwi_scp/parser.py b/kiwi_scp/parser.py deleted file mode 100644 index 5276152..0000000 --- a/kiwi_scp/parser.py +++ /dev/null @@ -1,66 +0,0 @@ -# system -import argparse - -# local -from ._constants import COMMAND_HELP_TEXT_NAME, KIWI_HELP_TEXT_NAME - - -class Parser: - """Singleton: Main CLI arguments parser""" - - class __Parser: - """Singleton type""" - - # argparse objects - __parser = None - __subparsers = None - __args = None - - def __init__(self): - # add version data from separate file (keeps default config cleaner) - with open(KIWI_HELP_TEXT_NAME, 'r') as stream: - kiwi_help = stream.read() - - with open(COMMAND_HELP_TEXT_NAME, 'r') as stream: - command_help_text = stream.read() - - # create main parser - self.__parser = argparse.ArgumentParser( - prog='kiwi', - description=kiwi_help, - epilog=command_help_text, - ) - self.__parser.formatter_class = argparse.RawDescriptionHelpFormatter - - # main arguments - self.__parser.add_argument( - '-v', '--verbosity', - action='count', default=0 - ) - - # attach subparsers - self.__subparsers = self.__parser.add_subparsers() - self.__subparsers.required = True - self.__subparsers.dest = 'command' - - def get_subparsers(self): - return self.__subparsers - - def get_args(self): - if self.__args is None: - # parse args if needed - self.__args, unknowns = self.__parser.parse_known_args() - self.__args.unknowns = unknowns - - return self.__args - - __instance = None - - def __init__(self): - if Parser.__instance is None: - # create singleton - Parser.__instance = Parser.__Parser() - - def __getattr__(self, item): - """Inner singleton direct access""" - return getattr(self.__instance, item) diff --git a/kiwi_scp/project.py b/kiwi_scp/project.py index 60a3fbd..a5b1bf7 100644 --- a/kiwi_scp/project.py +++ b/kiwi_scp/project.py @@ -1,133 +1,74 @@ -import logging -import os +import functools +from pathlib import Path +from typing import TYPE_CHECKING, Optional, Dict, Any -from ._constants import CONF_DIRECTORY_NAME -from .config import LoadedConfig -from .executable import Executable +import attr +from ruamel.yaml import CommentedMap + +from ._constants import COMPOSE_FILE_NAME, CONFIG_DIRECTORY_NAME +from .config import ProjectConfig +from .service import Service +from .services import Services +from .yaml import YAML + +if TYPE_CHECKING: + from .instance import Instance +@attr.s class Project: - __name = None + directory: Path = attr.ib() + parent_instance: "Instance" = attr.ib() - def __init__(self, name): - self.__name = name + @staticmethod + @functools.lru_cache(maxsize=10) + def _parse_compose_file(directory: Path) -> CommentedMap: + with open(directory.joinpath(COMPOSE_FILE_NAME), "r") as cf: + return YAML().load(cf) - @classmethod - def from_file_name(cls, file_name): - if os.path.isdir(file_name): - config = LoadedConfig.get() + @property + def name(self) -> str: + return self.directory.name - if file_name.endswith(config['markers:disabled']): - file_name = file_name[:-len(config['markers:disabled'])] + @property + def config(self) -> Optional[ProjectConfig]: + return self.parent_instance.config.get_project_config(self.name) - if file_name.endswith(config['markers:project']): - file_name = file_name[:-len(config['markers:project'])] - return cls(file_name) + @property + def process_kwargs(self) -> Dict[str, Any]: + directory: Path = self.directory + project_name: str = self.name + kiwi_hub_name: str = self.parent_instance.config.network.name + kiwi_instance_dir: Path = self.parent_instance.config.storage.directory + kiwi_config_dir: Path = kiwi_instance_dir.joinpath(CONFIG_DIRECTORY_NAME) + kiwi_project_dir: Path = kiwi_instance_dir.joinpath(project_name) - return None + if self.config.override_storage is not None: + kiwi_project_dir = self.config.override_storage.directory - def get_name(self): - return self.__name + result: Dict[str, Any] = { + "cwd": str(directory), + "env": { + "COMPOSE_PROJECT_NAME": project_name, + "KIWI_HUB_NAME": kiwi_hub_name, + "KIWI_INSTANCE": str(kiwi_instance_dir), + "KIWI_CONFIG": str(kiwi_config_dir), + "KIWI_PROJECT": str(kiwi_project_dir), + }, + } - def dir_name(self): - if self.is_enabled(): - return self.enabled_dir_name() - elif self.is_disabled(): - return self.disabled_dir_name() - else: - return None + result["env"].update(self.parent_instance.config.environment) - def enabled_dir_name(self): - return f"{self.__name}{LoadedConfig.get()['markers:project']}" + return result - def disabled_dir_name(self): - return f"{self.enabled_dir_name()}{LoadedConfig.get()['markers:disabled']}" + @property + def services(self) -> Services: + yml = Project._parse_compose_file(self.directory) - def conf_dir_name(self): - return os.path.join(self.dir_name(), CONF_DIRECTORY_NAME) - - def compose_file_name(self): - return os.path.join(self.dir_name(), 'docker-compose.yml') - - def target_dir_name(self): - return os.path.join(LoadedConfig.get()['runtime:storage'], self.enabled_dir_name()) - - def exists(self): - return os.path.isdir(self.enabled_dir_name()) or os.path.isdir(self.disabled_dir_name()) - - def is_enabled(self): - return os.path.isdir(self.enabled_dir_name()) - - def is_disabled(self): - return os.path.isdir(self.disabled_dir_name()) - - def has_configs(self): - return os.path.isdir(self.conf_dir_name()) - - def __update_kwargs(self, kwargs): - if not self.is_enabled(): - # cannot compose in a disabled project - logging.warning(f"Project '{self.get_name()}' is not enabled!") - return False - - config = LoadedConfig.get() - - # execute command in project directory - kwargs['cwd'] = self.dir_name() - - # ensure there is an environment - if 'env' not in kwargs: - kwargs['env'] = {} - - # create environment variables for docker commands - kwargs['env'].update({ - 'COMPOSE_PROJECT_NAME': self.get_name(), - 'KIWI_HUB_NAME': config['network:name'], - 'TARGETROOT': config['runtime:storage'], - 'CONFDIR': os.path.join(config['runtime:storage'], CONF_DIRECTORY_NAME), - 'TARGETDIR': self.target_dir_name() - }) - - # add common environment from config - if config['runtime:env'] is not None: - kwargs['env'].update(config['runtime:env']) - - logging.debug(f"kwargs updated: {kwargs}") - - return True - - def compose_run(self, compose_args, **kwargs): - if self.__update_kwargs(kwargs): - Executable('docker-compose').run(compose_args, **kwargs) - - def compose_run_less(self, compose_args, **kwargs): - if self.__update_kwargs(kwargs): - Executable('docker-compose').run_less(compose_args, **kwargs) - - def enable(self): - if self.is_disabled(): - logging.info(f"Enabling project '{self.get_name()}'") - os.rename(self.dir_name(), self.enabled_dir_name()) - - elif self.is_enabled(): - logging.warning(f"Project '{self.get_name()}' is enabled!") - - else: - logging.warning(f"Project '{self.get_name()}' not found in instance!") - return False - - return True - - def disable(self): - if self.is_enabled(): - logging.info(f"Disabling project '{self.get_name()}'") - os.rename(self.dir_name(), self.disabled_dir_name()) - - elif self.is_disabled(): - logging.warning(f"Project '{self.get_name()}' is disabled!") - - else: - logging.warning(f"Project '{self.get_name()}' not found in instance!") - return False - - return True + return Services([ + Service( + name=name, + content=content, + parent_project=self, + ) for name, content in yml["services"].items() + ]) diff --git a/kiwi_scp/projects.py b/kiwi_scp/projects.py deleted file mode 100644 index 1444646..0000000 --- a/kiwi_scp/projects.py +++ /dev/null @@ -1,83 +0,0 @@ -import os - -from .project import Project - - -class Projects: - __projects = None - - def __getitem__(self, item): - return self.__projects[item] - - def __str__(self): - return str([ - project.get_name() - for project - in self.__projects - ]) - - def __bool__(self): - return bool(self.__projects) - - @classmethod - def from_names(cls, project_names): - result = cls() - result.__projects = [ - Project(name) - for name in project_names if isinstance(name, str) - ] - return result - - @classmethod - def from_projects(cls, projects): - result = cls() - result.__projects = [ - project - for project in projects if isinstance(project, Project) - ] - return result - - @classmethod - def from_dir(cls, directory='.'): - return cls.from_projects([ - Project.from_file_name(file_name) - for file_name in os.listdir(directory) - ]) - - @classmethod - def from_args(cls, args): - if args is not None and 'projects' in args: - if isinstance(args.projects, list) and args.projects: - return cls.from_names(args.projects) - - elif isinstance(args.projects, str): - return cls.from_names([args.projects]) - - return cls() - - def filter_exists(self): - result = Projects() - result.__projects = [ - project - for project in self.__projects - if project.exists() - ] - return result - - def filter_enabled(self): - result = Projects() - result.__projects = [ - project - for project in self.__projects - if project.is_enabled() - ] - return result - - def filter_disabled(self): - result = Projects() - result.__projects = [ - project - for project in self.__projects - if project.is_disabled() - ] - return result diff --git a/kiwi_scp/rootkit.py b/kiwi_scp/rootkit.py index a3c7937..1be6ab6 100644 --- a/kiwi_scp/rootkit.py +++ b/kiwi_scp/rootkit.py @@ -1,86 +1,94 @@ -# system +import functools import logging -import os import subprocess +from pathlib import Path +from typing import Optional, TypeVar, Union, Sequence, Any + +import attr -# local from ._constants import IMAGES_DIRECTORY_NAME, LOCAL_IMAGES_NAME, DEFAULT_IMAGE_NAME -from .executable import Executable - - -def _prefix_path(prefix, path): - if isinstance(path, str): - abs_path = os.path.abspath(path) - return os.path.realpath(f"{prefix}/{abs_path}") - - elif isinstance(path, list): - return [_prefix_path(prefix, p) for p in path] - - -def prefix_path_mnt(path): - return _prefix_path('/mnt/', path) - - -def _image_name(image_tag): - if image_tag is not None: - return f"{LOCAL_IMAGES_NAME}:{image_tag}" - else: - return DEFAULT_IMAGE_NAME +from .executable import DOCKER_EXE + +_logger = logging.getLogger(__name__) + +ROOTKIT_PREFIX = Path("/mnt") +@attr.s class Rootkit: - class __Rootkit: - __image_tag = None + image_tag: str = attr.ib() - def __init__(self, image_tag=None): - self.__image_tag = image_tag + @staticmethod + @functools.lru_cache(maxsize=None) + def __image_name(image_tag: Optional[str]) -> str: + if image_tag is not None: + return f"{LOCAL_IMAGES_NAME}:{image_tag}" + else: + return DEFAULT_IMAGE_NAME - def __exists(self): - ps = Executable('docker').run([ - 'images', - '--filter', f"reference={_image_name(self.__image_tag)}", - '--format', '{{.Repository}}:{{.Tag}}' - ], stdout=subprocess.PIPE) + @staticmethod + @functools.lru_cache(maxsize=None) + def __exists(image_tag: str) -> bool: + ps = DOCKER_EXE.run([ + "images", + "--filter", f"reference={Rootkit.__image_name(image_tag)}", + "--format", "{{.Repository}}:{{.Tag}}" + ], stdout=subprocess.PIPE) - return str(ps.stdout, 'utf-8').strip() == _image_name(self.__image_tag) + return str(ps.stdout, "utf-8").strip() == Rootkit.__image_name(image_tag) + + def __build_image(self) -> None: + if Rootkit.__exists(self.image_tag): + _logger.info(f"Using image {Rootkit.__image_name(self.image_tag)}") + else: + if self.image_tag is None: + _logger.info(f"Pulling image {Rootkit.__image_name(self.image_tag)}") + DOCKER_EXE.run([ + "pull", Rootkit.__image_name(self.image_tag) + ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - def __build_image(self): - if self.__exists(): - logging.info(f"Using image {_image_name(self.__image_tag)}") else: - if self.__image_tag is None: - logging.info(f"Pulling image {_image_name(self.__image_tag)}") - Executable('docker').run([ - 'pull', _image_name(self.__image_tag) - ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + _logger.info(f"Building image {Rootkit.__image_name(self.image_tag)}") + DOCKER_EXE.run([ + "build", + "-t", Rootkit.__image_name(self.image_tag), + "-f", f"{IMAGES_DIRECTORY_NAME}/{self.image_tag}.Dockerfile", + f"{IMAGES_DIRECTORY_NAME}" + ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - else: - logging.info(f"Building image {_image_name(self.__image_tag)}") - Executable('docker').run([ - 'build', - '-t', _image_name(self.__image_tag), - '-f', f"{IMAGES_DIRECTORY_NAME}/{self.__image_tag}.Dockerfile", - f"{IMAGES_DIRECTORY_NAME}" - ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + def run(self, process_args, **kwargs) -> Optional[subprocess.CompletedProcess]: + any_sequence = TypeVar("any_sequence", Union[str, Path, Any], Sequence[Union[str, Path, Any]]) - def run(self, process_args, **kwargs): - self.__build_image() - Executable('docker').run([ - 'run', '--rm', - '-v', '/:/mnt', - '-u', 'root', - _image_name(self.__image_tag), - *process_args - ], **kwargs) + def parse_args(argument: any_sequence) -> any_sequence: + if isinstance(argument, str): + return argument - __image_tag = None - __instances = {} + elif isinstance(argument, Path): + if argument.is_absolute(): + argument = argument.relative_to("/") - def __init__(self, image_tag=None): - self.__image_tag = image_tag + return str(ROOTKIT_PREFIX.joinpath(argument)) - if _image_name(self.__image_tag) not in Rootkit.__instances: - Rootkit.__instances[_image_name(self.__image_tag)] = Rootkit.__Rootkit(image_tag) + elif not isinstance(argument, Sequence): + return str(argument) - def __getattr__(self, item): - return getattr(self.__instances[_image_name(self.__image_tag)], item) + else: + parsed = [parse_args(path) for path in argument] + + flat = [] + for item in parsed: + if not isinstance(item, list): + flat.append(item) + else: + flat.extend(item) + + return flat + + self.__build_image() + return DOCKER_EXE.run([ + "run", "--rm", + "-v", f"/:{ROOTKIT_PREFIX!s}", + "-u", "root", + Rootkit.__image_name(self.image_tag), + *parse_args(process_args) + ], **kwargs) diff --git a/kiwi_scp/runner.py b/kiwi_scp/runner.py deleted file mode 100644 index a2b33dd..0000000 --- a/kiwi_scp/runner.py +++ /dev/null @@ -1,73 +0,0 @@ -# system -import logging -import subprocess - -# local -from . import subcommands -from .executable import Executable -from .parser import Parser - - -class Runner: - """Singleton: Subcommands setup and run""" - - class __Runner: - """Singleton type""" - - __commands = [] - - def __init__(self): - # probe for Docker access - try: - Executable('docker').run([ - 'ps' - ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - - except subprocess.CalledProcessError: - logging.critical("Cannot access docker, please get into the docker group or run as root!") - quit(1) - - # setup all subcommands - for className in subcommands.__all__: - cmd = getattr(subcommands, className) - self.__commands.append(cmd()) - - def run(self, command=None, args=None): - """run the desired subcommand""" - - if args is None: - args = Parser().get_args() - - if command is None: - command = args.command - - for cmd in self.__commands: - if str(cmd) == command: - # command found - logging.debug(f"Running '{cmd}' with args: {args}") - - try: - result = cmd.run(self, args) - - except KeyboardInterrupt: - print() - logging.warning(f"'{cmd}' aborted, inputs may have been discarded.") - result = False - - return result - - # command not found - logging.error(f"kiwi command '{command}' unknown") - return False - - __instance = None - - def __init__(self): - if Runner.__instance is None: - # create singleton - Runner.__instance = Runner.__Runner() - - def __getattr__(self, item): - """Inner singleton direct access""" - - return getattr(self.__instance, item) diff --git a/kiwi_scp/scripts/kiwi.py b/kiwi_scp/scripts/kiwi.py old mode 100755 new mode 100644 index 9bcbc20..6f96b5d --- a/kiwi_scp/scripts/kiwi.py +++ b/kiwi_scp/scripts/kiwi.py @@ -1,38 +1,43 @@ -#!/usr/bin/env python3 - -# system import logging -# local -import kiwi_scp +import click + +from kiwi_scp.commands import KiwiCLI -def set_verbosity(logger, handler, verbosity): - """set logging default verbosity level and format""" +@click.option( + "-v", "--verbose", + help="increase output verbosity", + count=True, +) +@click.command(cls=KiwiCLI) +def main(verbose: int) -> None: + """kiwi is the simple tool for managing container servers. - if verbosity >= 2: + \b + - Manage full instances using just your favorite version control system + - Group services into projects, each with their own docker-compose.yml + - Build service-specific, private docker images from Dockerfiles + - Make use of the local file system by referring to ${TARGETDIR}, ${TARGETROOT} and ${CONFIGDIR} in compose files + - Create your own instance-global variables for compose files using the kiwi.yml "environment" section + """ + + if verbose >= 2: log_level = logging.DEBUG log_format = "[%(asctime)s] %(levelname)s @ %(filename)s:%(funcName)s:%(lineno)d: %(message)s" - elif verbosity >= 1: + elif verbose >= 1: log_level = logging.INFO log_format = "[%(asctime)s] %(levelname)s: %(message)s" else: log_level = logging.WARNING log_format = "%(levelname)s: %(message)s" - logger.setLevel(log_level) - handler.setFormatter(logging.Formatter(log_format)) - - -def main(): # add a new handler (needed to set the level) log_handler = logging.StreamHandler() logging.getLogger().addHandler(log_handler) - set_verbosity(logging.getLogger(), log_handler, kiwi_scp.verbosity()) - # run the app - if not kiwi_scp.run(): - quit(1) + logging.getLogger().setLevel(log_level) + log_handler.setFormatter(logging.Formatter(log_format)) if __name__ == "__main__": diff --git a/kiwi_scp/service.py b/kiwi_scp/service.py new file mode 100644 index 0000000..220aa63 --- /dev/null +++ b/kiwi_scp/service.py @@ -0,0 +1,61 @@ +import logging +import re +import subprocess +from itertools import zip_longest +from pathlib import Path +from typing import TYPE_CHECKING, Generator, Sequence + +import attr +from ruamel.yaml import CommentedMap + +from .executable import COMPOSE_EXE + +if TYPE_CHECKING: + from .project import Project + +_logger = logging.getLogger(__name__) + + +@attr.s +class Service: + name: str = attr.ib() + content: CommentedMap = attr.ib() + parent_project: "Project" = attr.ib() + + _RE_CONFIGDIR = re.compile(r"^\s*\$(?:CONFIGDIR|{CONFIGDIR})/+(.*)$", flags=re.UNICODE) + + @property + def configs(self) -> Generator[Path, None, None]: + if "volumes" not in self.content: + return + + for volume in self.content["volumes"]: + host_part = volume.split(":")[0] + cd_match = Service._RE_CONFIGDIR.match(host_part) + + if cd_match: + yield Path(cd_match.group(1)) + + def has_executable(self, exe_name: str) -> bool: + try: + # test if desired executable exists + COMPOSE_EXE.run( + ["exec", "-T", self.name, "/bin/sh", "-c", f"command -v {exe_name}"], + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + **self.parent_project.process_kwargs, + ) + return True + + except subprocess.CalledProcessError: + return False + + def existing_executables(self, exe_names: Sequence[str]) -> Generator[str, None, None]: + for cur, nxt in zip_longest(exe_names, exe_names[1:]): + if self.has_executable(cur): + # found working shell + _logger.debug(f"Found executable '{cur}'") + yield cur + + elif nxt is not None: + # try next in list + _logger.info(f"Executable '{cur}' not found in container, trying '{nxt}'") diff --git a/kiwi_scp/services.py b/kiwi_scp/services.py new file mode 100644 index 0000000..f1b3455 --- /dev/null +++ b/kiwi_scp/services.py @@ -0,0 +1,91 @@ +import subprocess +from pathlib import Path +from typing import List, Generator, Optional, TYPE_CHECKING, TypeVar, Union + +import attr + +from .rootkit import Rootkit +from .yaml import YAML + +if TYPE_CHECKING: + from .project import Project + from .service import Service + + +@attr.s +class Services: + content: List["Service"] = attr.ib() + + def __str__(self) -> str: + return YAML().dump({ + "services": { + service.name: service.content + for service in self.content + }, + "configs": [ + str(config) + for config in self.configs + ], + }).strip() + + def __bool__(self) -> bool: + return bool(self.content) + + @property + def parent_project(self) -> Optional["Project"]: + if not self: + return + + return self.content[0].parent_project + + @property + def configs(self) -> Generator[Path, None, None]: + for service in self.content: + yield from service.configs + + def copy_configs(self) -> None: + path_str_list = TypeVar("path_str_list", Union[Path, str], List[Union[Path, str]]) + + def prefix_path(path: path_str_list, prefix: Path) -> path_str_list: + if isinstance(path, Path): + return prefix.absolute().joinpath(path) + + elif isinstance(path, str): + return prefix_path(Path(path), prefix) + + elif isinstance(path, list): + return [prefix_path(p, prefix) for p in path] + + project = self.parent_project + + if project is None: + return + + instance = project.parent_instance + cfgs = list(self.configs) + + local_cfgs = prefix_path(cfgs, instance.config_directory) + storage_cfgs = prefix_path(cfgs, instance.storage_config_directory) + storage_dirs = [path.parent for path in storage_cfgs] + + Rootkit("rsync").run([ + "mkdir", "-p", storage_dirs + ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + Rootkit("rsync").run([ + "rsync", "-rpt", list(zip(local_cfgs, storage_cfgs)) + ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + @property + def names(self) -> Generator[str, None, None]: + return ( + service.name + for service in self.content + ) + + def filter_existing(self, service_names: List[str]) -> "Services": + return Services([ + service + for service in self.content + if service.name in service_names + ]) diff --git a/kiwi_scp/subcommand.py b/kiwi_scp/subcommand.py deleted file mode 100644 index b5955d3..0000000 --- a/kiwi_scp/subcommand.py +++ /dev/null @@ -1,128 +0,0 @@ -# system -import logging -import os - -# local -from .parser import Parser -from .projects import Projects - - -class SubCommand: - """represents kiwi [anything] command""" - - # actual command string - __name = None - # command parser - _sub_parser = None - - _action = None - - def __init__(self, name, action, add_parser=True, **kwargs): - self.__name = name - self._action = action - - if add_parser: - self._sub_parser = Parser().get_subparsers().add_parser( - name, - **kwargs - ) - - def __str__(self): - return self.__name - - def _run_instance(self, runner, args): - pass - - def run(self, runner, args): - """actually run command with parsed CLI args""" - - # run for entire instance - logging.info(f"{self._action} kiwi-scp instance at '{os.getcwd()}'") - return self._run_instance(runner, args) - - -class ProjectCommand(SubCommand): - """this command concerns a project in current instance""" - - def __init__(self, name, num_projects, action, add_parser=True, **kwargs): - super().__init__( - name, action=action, add_parser=add_parser, - **kwargs - ) - - if num_projects == 1: - projects = "a project" - else: - projects = "project(s)" - - self._sub_parser.add_argument( - 'projects', metavar='project', nargs=num_projects, type=str, - help=f"select {projects} in this instance" - ) - - def _run_instance(self, runner, args): - # default: run for all enabled projects - return self._run_projects(runner, args, Projects.from_dir().filter_enabled()) - - def _run_projects(self, runner, args, projects): - # default: run for all given projects - return all([ - self._run_project(runner, args, project) - for project in projects - ]) - - def _run_project(self, runner, args, project): - pass - - def run(self, runner, args): - projects = Projects.from_args(args) - - if projects: - # project(s) given - logging.info(f"{self._action} projects {projects}") - return self._run_projects(runner, args, projects) - - else: - return super().run(runner, args) - - -class ServiceCommand(ProjectCommand): - """this command concerns service(s) in a project""" - - def __init__(self, name, num_projects, num_services, action, add_parser=True, **kwargs): - super().__init__( - name, num_projects=num_projects, action=action, add_parser=add_parser, - **kwargs - ) - - if (isinstance(num_projects, str) and num_projects == '*') \ - or (isinstance(num_projects, int) and num_projects > 1): - raise ValueError(f"Invalid choice for project count: {num_projects}") - - if num_services == 1: - services = "a service" - else: - services = "service(s)" - - self._sub_parser.add_argument( - 'services', metavar='service', nargs=num_services, type=str, - help=f"select {services} in a project" - ) - - def _run_project(self, runner, args, project): - # default: run with empty service list - return self._run_services(runner, args, project, []) - - def _run_services(self, runner, args, project, services): - pass - - def run(self, runner, args): - if 'services' in args and args.services: - project = Projects.from_args(args)[0] - - # run for service(s) inside project - logging.info(f"{self._action} project '{project.get_name()}', services {args.services}") - return self._run_services(runner, args, project, args.services) - - else: - return super().run(runner, args) diff --git a/kiwi_scp/subcommands/__init__.py b/kiwi_scp/subcommands/__init__.py deleted file mode 100644 index f958982..0000000 --- a/kiwi_scp/subcommands/__init__.py +++ /dev/null @@ -1,39 +0,0 @@ -# local -from ._hidden import ConfCopyCommand, NetUpCommand - -from .build import BuildCommand -from .cmd import CmdCommand -from .disable import DisableCommand -from .down import DownCommand -from .enable import EnableCommand -from .init import InitCommand -from .logs import LogsCommand -from .new import NewCommand -from .pull import PullCommand -from .push import PushCommand -from .restart import RestartCommand -from .shell import ShellCommand -from .show import ShowCommand -from .up import UpCommand -from .update import UpdateCommand - -__all__ = [ - 'ConfCopyCommand', - 'NetUpCommand', - - 'BuildCommand', - 'CmdCommand', - 'DisableCommand', - 'DownCommand', - 'EnableCommand', - 'InitCommand', - 'LogsCommand', - 'NewCommand', - 'PullCommand', - 'PushCommand', - 'RestartCommand', - 'ShellCommand', - 'ShowCommand', - 'UpCommand', - 'UpdateCommand', -] diff --git a/kiwi_scp/subcommands/_hidden.py b/kiwi_scp/subcommands/_hidden.py deleted file mode 100644 index 062495b..0000000 --- a/kiwi_scp/subcommands/_hidden.py +++ /dev/null @@ -1,85 +0,0 @@ -# system -import logging -import subprocess - -# local -from ..config import LoadedConfig -from ..executable import Executable -from ..projects import Projects -from ..rootkit import Rootkit, prefix_path_mnt -from ..subcommand import SubCommand - - -class ConfCopyCommand(SubCommand): - """kiwi conf-copy""" - - def __init__(self): - super().__init__( - 'conf-copy', - action="Syncing all configs for", add_parser=False, - description="Synchronize all config files to target directory" - ) - - def _run_instance(self, runner, args): - conf_dirs = [ - project.conf_dir_name() - for project in Projects.from_dir().filter_enabled() - if project.has_configs() - ] - - if conf_dirs: - # add target directory - conf_dirs.append(LoadedConfig.get()['runtime:storage']) - logging.info(f"Sync directories: {conf_dirs}") - - Rootkit('rsync').run([ - 'rsync', '-rpt', '--delete', *prefix_path_mnt(conf_dirs) - ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - - return True - - -def _find_net(net_name): - ps = Executable('docker').run([ - 'network', 'ls', '--filter', f"name={net_name}", '--format', '{{.Name}}' - ], stdout=subprocess.PIPE) - - net_found = str(ps.stdout, 'utf-8').strip() - - return net_found == net_name - - -class NetUpCommand(SubCommand): - """kiwi net-up""" - - def __init__(self): - super().__init__( - 'net-up', - action="Creating the local network hub for", add_parser=False, - description="Create the local network hub for this instance" - ) - - def _run_instance(self, runner, args): - config = LoadedConfig.get() - net_name = config['network:name'] - net_cidr = config['network:cidr'] - - if _find_net(net_name): - logging.info(f"Network '{net_name}' already exists") - return True - - try: - Executable('docker').run([ - 'network', 'create', - '--driver', 'bridge', - '--internal', - '--subnet', net_cidr, - net_name - ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - logging.info(f"Network '{net_name}' created") - - except subprocess.CalledProcessError: - logging.error(f"Error creating network '{net_name}'") - return False - - return True diff --git a/kiwi_scp/subcommands/build.py b/kiwi_scp/subcommands/build.py deleted file mode 100644 index 35cb4e5..0000000 --- a/kiwi_scp/subcommands/build.py +++ /dev/null @@ -1,18 +0,0 @@ -# local -from ..subcommand import ServiceCommand - - -class BuildCommand(ServiceCommand): - """kiwi build""" - - def __init__(self): - super().__init__( - 'build', num_projects='?', num_services='*', - action="Building images for", - description="Build images for the whole instance, a project or service(s) inside a project" - ) - - def _run_services(self, runner, args, project, services): - project.compose_run(['build', '--pull', *services]) - - return True diff --git a/kiwi_scp/subcommands/cmd.py b/kiwi_scp/subcommands/cmd.py deleted file mode 100644 index c890edf..0000000 --- a/kiwi_scp/subcommands/cmd.py +++ /dev/null @@ -1,40 +0,0 @@ -# system -import logging - -# local -from ..subcommand import ProjectCommand - - -class CmdCommand(ProjectCommand): - """kiwi cmd""" - - def __init__(self): - super().__init__( - 'cmd', num_projects=1, - action="Running docker-compose in", - description="Run raw docker-compose command in a project" - ) - - # command for docker-compose - self._sub_parser.add_argument( - 'compose_cmd', metavar='cmd', type=str, - help="command for 'docker-compose'" - ) - - # arguments for docker-compose command - self._sub_parser.add_argument( - 'compose_args', metavar='arg', nargs='*', type=str, - help="arguments for 'docker-compose' commands" - ) - - def _run_project(self, runner, args, project): - if args.unknowns: - args.compose_args = [*args.compose_args, *args.unknowns] - args.unknowns = [] - - logging.debug(f"Updated args: {args}") - - # run with split compose_cmd argument - project.compose_run([args.compose_cmd, *args.compose_args]) - - return True diff --git a/kiwi_scp/subcommands/disable.py b/kiwi_scp/subcommands/disable.py deleted file mode 100644 index d4d762d..0000000 --- a/kiwi_scp/subcommands/disable.py +++ /dev/null @@ -1,16 +0,0 @@ -# local -from ..subcommand import ProjectCommand - - -class DisableCommand(ProjectCommand): - """kiwi disable""" - - def __init__(self): - super().__init__( - 'disable', num_projects='+', - action="Disabling", - description="Disable project(s) in this instance" - ) - - def _run_project(self, runner, args, project): - return project.disable() diff --git a/kiwi_scp/subcommands/down.py b/kiwi_scp/subcommands/down.py deleted file mode 100644 index b0a1147..0000000 --- a/kiwi_scp/subcommands/down.py +++ /dev/null @@ -1,56 +0,0 @@ -# system -import logging -import subprocess - -# local -from ._hidden import _find_net -from ..config import LoadedConfig -from ..executable import Executable -from ..misc import are_you_sure -from ..subcommand import ServiceCommand - - -class DownCommand(ServiceCommand): - """kiwi down""" - - def __init__(self): - super().__init__( - 'down', num_projects='?', num_services='*', - action="Bringing down", - description="Bring down the whole instance, a project or service(s) inside a project" - ) - - def _run_instance(self, runner, args): - net_name = LoadedConfig.get()['network:name'] - - if are_you_sure([ - "This will bring down the entire instance.", - "", - "This may not be what you intended, because:", - " - Bringing down the instance stops ALL services in here", - ]): - if super()._run_instance(runner, args): - # remove the hub network afterwards - if _find_net(net_name): - Executable('docker').run([ - 'network', 'rm', net_name - ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - - logging.info(f"Network '{net_name}' removed") - - else: - logging.info(f"Network '{net_name}' does not exist") - - return True - - return False - - def _run_project(self, runner, args, project): - project.compose_run(['down']) - return True - - def _run_services(self, runner, args, project, services): - project.compose_run(['stop', *services]) - project.compose_run(['rm', '-f', *services]) - - return True diff --git a/kiwi_scp/subcommands/enable.py b/kiwi_scp/subcommands/enable.py deleted file mode 100644 index 1262a7f..0000000 --- a/kiwi_scp/subcommands/enable.py +++ /dev/null @@ -1,16 +0,0 @@ -# local -from ..subcommand import ProjectCommand - - -class EnableCommand(ProjectCommand): - """kiwi enable""" - - def __init__(self): - super().__init__( - 'enable', num_projects='+', - action="Enabling", - description="Enable project(s) in this instance" - ) - - def _run_project(self, runner, args, project): - return project.enable() diff --git a/kiwi_scp/subcommands/init.py b/kiwi_scp/subcommands/init.py deleted file mode 100644 index 1ca9c6a..0000000 --- a/kiwi_scp/subcommands/init.py +++ /dev/null @@ -1,63 +0,0 @@ -# system -import logging -import os - -# local -from .._constants import KIWI_CONF_NAME -from ..config import DefaultConfig, LoadedConfig -from ..subcommand import SubCommand - - -class InitCommand(SubCommand): - """kiwi init""" - - def __init__(self): - super().__init__( - 'init', - action=f"Initializing '{KIWI_CONF_NAME}' in", - description="Initialize or reconfigure kiwi-scp instance" - ) - - # -f switch: Initialize with default config - self._sub_parser.add_argument( - '-f', '--force', - action='store_true', - help=f"use default values even if {KIWI_CONF_NAME} is present" - ) - - # -s switch: Show current config instead - self._sub_parser.add_argument( - '-s', '--show', - action='store_true', - help=f"show effective {KIWI_CONF_NAME} contents instead" - ) - - def _run_instance(self, runner, args): - config = LoadedConfig.get() - - # check show switch - if args.show: - print(config) - return True - - # check force switch - if args.force and os.path.isfile(KIWI_CONF_NAME): - logging.warning(f"Overwriting existing '{KIWI_CONF_NAME}'!") - config = DefaultConfig.get() - - # version - config.user_query('version') - - # runtime - config.user_query('runtime:storage') - - # markers - config.user_query('markers:project') - config.user_query('markers:disabled') - - # network - config.user_query('network:name') - config.user_query('network:cidr') - - config.save() - return True diff --git a/kiwi_scp/subcommands/logs.py b/kiwi_scp/subcommands/logs.py deleted file mode 100644 index 2407220..0000000 --- a/kiwi_scp/subcommands/logs.py +++ /dev/null @@ -1,40 +0,0 @@ -# local -from ..subcommand import ServiceCommand - - -class LogsCommand(ServiceCommand): - """kiwi logs""" - - def __init__(self): - super().__init__( - 'logs', num_projects=1, num_services='*', - action="Showing logs of", - description="Show logs of a project or service(s) inside a project" - ) - - # -f switch: Follow logs - self._sub_parser.add_argument( - '-f', '--follow', action='store_true', - help="output appended data as log grows" - ) - - def _run_services(self, runner, args, project, services): - # include timestamps - compose_cmd = ['logs', '-t'] - - # handle following the log output - if args.follow: - compose_cmd = [*compose_cmd, '-f', '--tail=10'] - - # append if one or more services are given - if services: - compose_cmd = [*compose_cmd, *args.services] - - if args.follow: - project.compose_run(compose_cmd) - - else: - # use 'less' viewer if output is static - project.compose_run_less(compose_cmd) - - return True diff --git a/kiwi_scp/subcommands/new.py b/kiwi_scp/subcommands/new.py deleted file mode 100644 index 5fc5243..0000000 --- a/kiwi_scp/subcommands/new.py +++ /dev/null @@ -1,30 +0,0 @@ -# system -import logging -import os -import shutil - -# local -from .._constants import DEFAULT_DOCKER_COMPOSE_NAME -from ..subcommand import ProjectCommand - - -class NewCommand(ProjectCommand): - """kiwi new""" - - def __init__(self): - super().__init__( - 'new', num_projects='+', - action="Creating", - description="Create new empty project(s) in this instance" - ) - - def _run_project(self, runner, args, project): - if project.exists(): - logging.error(f"Project '{project.get_name()}' exists in this instance!") - return False - - else: - os.mkdir(project.disabled_dir_name()) - shutil.copy(DEFAULT_DOCKER_COMPOSE_NAME, project.compose_file_name()) - logging.debug(f"Project '{project.get_name()}' created") - return True diff --git a/kiwi_scp/subcommands/pull.py b/kiwi_scp/subcommands/pull.py deleted file mode 100644 index a1ed790..0000000 --- a/kiwi_scp/subcommands/pull.py +++ /dev/null @@ -1,18 +0,0 @@ -# local -from ..subcommand import ServiceCommand - - -class PullCommand(ServiceCommand): - """kiwi pull""" - - def __init__(self): - super().__init__( - 'pull', num_projects='?', num_services='*', - action="Pulling images for", - description="Pull images for the whole instance, a project or service(s) inside a project" - ) - - def _run_services(self, runner, args, project, services): - project.compose_run(['pull', '--ignore-pull-failures', *services]) - - return True diff --git a/kiwi_scp/subcommands/purge.py b/kiwi_scp/subcommands/purge.py deleted file mode 100644 index 86cd372..0000000 --- a/kiwi_scp/subcommands/purge.py +++ /dev/null @@ -1,45 +0,0 @@ -# system -import logging -import subprocess - -# local -from ._hidden import _find_net -from ..config import LoadedConfig -from ..executable import Executable -from ..misc import are_you_sure -from ..subcommand import SubCommand - - -class PurgeCommand(SubCommand): - """kiwi purge""" - - def __init__(self): - super().__init__( - 'purge', - action="Tearing down", - description="Remove all running docker artifacts of this instance" - ) - - def _run_instance(self, runner, args): - net_name = LoadedConfig.get()['network:name'] - - if not _find_net(net_name): - logging.info(f"Network '{net_name}' does not exist") - return True - - try: - if are_you_sure("This will bring down this instance's hub network!"): - if runner.run('down'): - Executable('docker').run([ - 'network', 'rm', net_name - ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - - logging.info(f"Network '{net_name}' removed") - else: - return False - - except subprocess.CalledProcessError: - logging.error(f"Error removing network '{net_name}'") - return False - - return True diff --git a/kiwi_scp/subcommands/push.py b/kiwi_scp/subcommands/push.py deleted file mode 100644 index 0f83c52..0000000 --- a/kiwi_scp/subcommands/push.py +++ /dev/null @@ -1,18 +0,0 @@ -# local -from ..subcommand import ServiceCommand - - -class PushCommand(ServiceCommand): - """kiwi push""" - - def __init__(self): - super().__init__( - 'push', num_projects='?', num_services='*', - action="Pushing images for", - description="Push images for the whole instance, a project or service(s) inside a project" - ) - - def _run_services(self, runner, args, project, services): - project.compose_run(['push', *services]) - - return True diff --git a/kiwi_scp/subcommands/restart.py b/kiwi_scp/subcommands/restart.py deleted file mode 100644 index 86fce22..0000000 --- a/kiwi_scp/subcommands/restart.py +++ /dev/null @@ -1,26 +0,0 @@ -# local -from ..misc import are_you_sure -from ..subcommand import ServiceCommand - - -class RestartCommand(ServiceCommand): - """kiwi restart""" - - def __init__(self): - super().__init__( - 'restart', num_projects='?', num_services='*', - action="Restarting", - description="Restart the whole instance, a project or service(s) inside a project" - ) - - def _run_instance(self, runner, args): - if are_you_sure([ - "This will restart the entire instance." - ]): - return super()._run_instance(runner, args) - - return False - - def _run_services(self, runner, args, project, services): - project.compose_run(['restart', *services]) - return True diff --git a/kiwi_scp/subcommands/shell.py b/kiwi_scp/subcommands/shell.py deleted file mode 100644 index 7547ced..0000000 --- a/kiwi_scp/subcommands/shell.py +++ /dev/null @@ -1,104 +0,0 @@ -# system -import logging -import subprocess - -from ..config import LoadedConfig -# local -from ..subcommand import ServiceCommand - - -def _service_has_executable(project, service, exe_name): - """ - Test if service in project has an executable exe_name in its PATH. - Requires /bin/sh. - """ - - try: - # test if desired shell exists - project.compose_run( - ['exec', service, '/bin/sh', '-c', f"command -v {exe_name}"], - check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL - ) - return True - - except subprocess.CalledProcessError as e: - # fallback - return False - - -def _find_shell(args, project, service): - """find first working shell (provided by config and args) in service in project""" - - # builtin shells: as a last resort, fallback to '/bin/sh' and 'sh' - shells = ['/bin/sh', 'sh'] - - # load favorite shells from config - config = LoadedConfig.get() - if config['runtime:shells']: - shells = [*config['runtime:shells'], *shells] - - # consider shell from args - if args.shell: - shells = [args.shell, *shells] - - logging.debug(f"Shells priority: {shells}") - - # actually try shells - for i, shell in enumerate(shells): - if _service_has_executable(project, service, shell): - # found working shell - logging.debug(f"Using shell '{shell}'") - return shell - - elif i + 1 < len(shells): - # try next in list - logging.info(f"Shell '{shell}' not found in container, trying '{shells[i + 1]}'") - - elif args.shell: - # not found, user suggestion provided - logging.warning(f"Could not find any working shell in this container. " - f"Launching provided '{args.shell}' nevertheless. " - f"Don't get mad if this fails!") - return args.shell - - else: - # not found, search exhausted - logging.error(f"Could not find any working shell among '{shells}' in this container. " - f"Please suggest a shell using the '-s SHELL' command line option!") - return None - - -class ShellCommand(ServiceCommand): - """kiwi shell""" - - def __init__(self): - super().__init__( - 'shell', num_projects=1, num_services=1, - action="Spawning shell in", - description="Spawn shell inside a project's service" - ) - - # -s argument: Select shell - self._sub_parser.add_argument( - '-s', '--shell', type=str, - help="shell to spawn" - ) - - # -u argument: Run as user - self._sub_parser.add_argument( - '-u', '--user', type=str, - help="container user to run shell" - ) - - def _run_services(self, runner, args, project, services): - service = services[0] - shell = _find_shell(args, project, service) - - user_args = ['-u', args.user] if args.user else [] - - if shell is not None: - # spawn shell - project.compose_run(['exec', *user_args, service, shell]) - return True - - return False diff --git a/kiwi_scp/subcommands/show.py b/kiwi_scp/subcommands/show.py deleted file mode 100644 index d8e7a34..0000000 --- a/kiwi_scp/subcommands/show.py +++ /dev/null @@ -1,98 +0,0 @@ -# system -import logging -import os -import yaml - -from ..project import Project -from ..projects import Projects -# local -from ..subcommand import ServiceCommand - - -def _print_list(strings): - if isinstance(strings, str): - print(f" - {strings}") - - elif isinstance(strings, Project): - _print_list(strings.get_name()) - - elif isinstance(strings, list): - for string in strings: - _print_list(string) - - else: - _print_list(list(strings)) - - -class ShowCommand(ServiceCommand): - """kiwi show""" - - def __init__(self): - super().__init__( - 'show', num_projects='?', num_services='*', - action="Showing", - description="Show projects in this instance, services inside a project or service(s) inside a project" - ) - - def _run_instance(self, runner, args): - print(f"kiwi-scp instance at '{os.getcwd()}'") - print("#########") - projects = Projects.from_dir() - - enabled_projects = projects.filter_enabled() - if enabled_projects: - print(f"Enabled projects:") - _print_list(enabled_projects) - - disabled_projects = projects.filter_disabled() - if disabled_projects: - print(f"Disabled projects:") - _print_list(disabled_projects) - - return True - - def _run_project(self, runner, args, project): - if not project.exists(): - logging.warning(f"Project '{project.get_name()}' not found") - return False - - print(f"Services in project '{project.get_name()}':") - print("#########") - - with open(project.compose_file_name(), 'r') as stream: - try: - docker_compose_yml = yaml.safe_load(stream) - _print_list(docker_compose_yml['services'].keys()) - - except yaml.YAMLError as exc: - logging.error(exc) - - return True - - def _run_services(self, runner, args, project, services): - if not project.exists(): - logging.error(f"Project '{project.get_name()}' not found") - return False - - print(f"Configuration of services {services} in project '{project.get_name()}':") - print("#########") - - with open(project.compose_file_name(), 'r') as stream: - try: - docker_compose_yml = yaml.safe_load(stream) - - for service_name in services: - try: - print(yaml.dump( - {service_name: docker_compose_yml['services'][service_name]}, - default_flow_style=False, sort_keys=False - ).strip()) - except KeyError: - logging.error(f"Service '{service_name}' not found") - - return True - - except yaml.YAMLError as exc: - logging.error(exc) - - return False diff --git a/kiwi_scp/subcommands/up.py b/kiwi_scp/subcommands/up.py deleted file mode 100644 index b369545..0000000 --- a/kiwi_scp/subcommands/up.py +++ /dev/null @@ -1,26 +0,0 @@ -# local -from ..subcommand import ServiceCommand - - -class UpCommand(ServiceCommand): - """kiwi up""" - - def __init__(self): - super().__init__( - 'up', num_projects='?', num_services='*', - action="Bringing up", - description="Bring up the whole instance, a project or service(s) inside a project" - ) - - def _run_instance(self, runner, args): - if runner.run('conf-copy'): - return super()._run_instance(runner, args) - - return False - - def _run_services(self, runner, args, project, services): - if runner.run('net-up'): - project.compose_run(['up', '-d', *services]) - return True - - return False diff --git a/kiwi_scp/subcommands/update.py b/kiwi_scp/subcommands/update.py deleted file mode 100644 index 35bc6dd..0000000 --- a/kiwi_scp/subcommands/update.py +++ /dev/null @@ -1,37 +0,0 @@ -# local -from ..misc import are_you_sure -from ..subcommand import ServiceCommand - - -class UpdateCommand(ServiceCommand): - """kiwi update""" - - def __init__(self): - super().__init__( - 'update', num_projects='?', num_services='*', - action="Updating", - description="Update the whole instance, a project or service(s) inside a project" - ) - - def _run_instance(self, runner, args): - if are_you_sure([ - "This will update the entire instance at once.", - "", - "This is probably not what you intended, because:", - " - Updates may take a long time", - " - Updates may break beloved functionality", - ]): - return super()._run_instance(runner, args) - - return False - - def _run_services(self, runner, args, project, services): - result = True - - result &= runner.run('build') - result &= runner.run('pull') - result &= runner.run('conf-copy') - result &= runner.run('down') - result &= runner.run('up') - - return result diff --git a/kiwi_scp/wstring.py b/kiwi_scp/wstring.py new file mode 100644 index 0000000..c23c31a --- /dev/null +++ b/kiwi_scp/wstring.py @@ -0,0 +1,97 @@ +import re +from enum import Enum, auto +from typing import List + +import attr +import wcwidth + + +class WAlignment(Enum): + LEFT = auto() + RIGHT = auto() + CENTER = auto() + + +@attr.s +class WString: + s: str = attr.ib() + + # from https://stackoverflow.com/a/38662876 + ANSI_ESCAPES = re.compile(r"(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]") + + def __str__(self) -> str: + return self.s + + def __len__(self) -> int: + return wcwidth.wcswidth(WString.ANSI_ESCAPES.sub("", self.s)) + + def pad(self, alignment: WAlignment = WAlignment.CENTER, wlen: int = 0, char: str = " ") -> "WString": + char = char[0] + + if alignment is WAlignment.LEFT: + return WString(f"{self}{char * wlen}") + elif alignment is WAlignment.RIGHT: + return WString(f"{char * wlen}{self}") + else: + pad_l, pad_r = wlen // 2, wlen - (wlen // 2) + return WString(f"{char * pad_l}{self}{char * pad_r}") + + +@attr.s +class WParagraph: + lines: List[WString] = attr.ib() + + def __str__(self) -> str: + return "\n".join( + str(line) + for line in self.lines + ) + + @classmethod + def from_strings(cls, *source: str) -> "WParagraph": + return cls([ + WString(line) + for line in source + ]) + + def align(self, alignment: WAlignment = WAlignment.CENTER, padding: int = 0, char: str = " ") -> "WParagraph": + total_length = max( + len(line) + for line in self.lines + ) + padding + pad_lengths = ( + total_length - len(line) + for line in self.lines + ) + + return WParagraph([ + line.pad(alignment, wlen, char) + for line, wlen in zip(self.lines, pad_lengths) + ]) + + def surround(self, char: str, padding: int = 1) -> "WParagraph": + char = char[0] + padding = " " * padding + + l_border, r_border = char + padding, padding + char + + lines = [ + WString(f"{l_border}{line}{r_border}") + for line in self.lines + ] + extra_line = char * len(lines[0]) + + return WParagraph([ + extra_line, + *lines, + extra_line, + ]) + + def emphasize(self, count: int = 3, padding: int = 1) -> "WParagraph": + padding = " " * padding + l_border, r_border = (">" * count) + padding, padding + ("<" * count) + + return WParagraph([ + WString(f"{l_border}{line}{r_border}") + for line in self.lines + ]) diff --git a/kiwi_scp/yaml.py b/kiwi_scp/yaml.py new file mode 100644 index 0000000..2d7a606 --- /dev/null +++ b/kiwi_scp/yaml.py @@ -0,0 +1,37 @@ +import re +from typing import Optional + +import ruamel.yaml +import ruamel.yaml.compat + +from ._constants import HEADER_KIWI_CONF_NAME + + +class YAML(ruamel.yaml.YAML): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.indent(sequence=4, offset=2) + + def dump(self, data, stream=None, **kwargs) -> Optional[str]: + into_str: bool = False + if stream is None: + into_str = True + stream = ruamel.yaml.compat.StringIO() + + super().dump(data, stream=stream, **kwargs) + if into_str: + return stream.getvalue() + + @staticmethod + def _format_kiwi_yml(yml_string: str) -> str: + # insert newline before every main key + yml_string = re.sub(r"^(\S)", r"\n\1", yml_string, flags=re.MULTILINE) + + # load header comment from file + with open(HEADER_KIWI_CONF_NAME, 'r') as stream: + yml_string = stream.read() + yml_string + + return yml_string + + def dump_kiwi_yml(self, data, **kwargs) -> Optional[str]: + return self.dump(data, transform=YAML._format_kiwi_yml, **kwargs) diff --git a/poetry.lock b/poetry.lock index dd53b7f..eecd76d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,45 +1,544 @@ [[package]] -name = "pyyaml" -version = "5.4.1" -description = "YAML parser and emitter for Python" +name = "atomicwrites" +version = "1.4.0" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "21.4.0" +description = "Classes Without Boilerplate" category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] + +[[package]] +name = "click" +version = "8.0.4" +description = "Composable command line interface toolkit" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} + +[[package]] +name = "colorama" +version = "0.4.4" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "coverage" +version = "6.2" +description = "Code coverage measurement for Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "dataclasses" +version = "0.8" +description = "A backport of the dataclasses module for Python 3.6" +category = "main" +optional = false +python-versions = ">=3.6, <3.7" + +[[package]] +name = "distlib" +version = "0.3.4" +description = "Distribution utilities" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "filelock" +version = "3.4.1" +description = "A platform independent file lock." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] +testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"] + +[[package]] +name = "importlib-metadata" +version = "4.8.3" +description = "Read metadata from Python packages" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} +zipp = ">=0.5" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +perf = ["ipython"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] + +[[package]] +name = "importlib-resources" +version = "5.4.0" +description = "Read resources from Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-black (>=0.3.7)", "pytest-mypy"] + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "platformdirs" +version = "2.4.0" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] +test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] + +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pydantic" +version = "1.9.0" +description = "Data validation and settings management using python 3.6 type hinting" +category = "main" +optional = false +python-versions = ">=3.6.1" + +[package.dependencies] +dataclasses = {version = ">=0.6", markers = "python_version < \"3.7\""} +typing-extensions = ">=3.7.4.3" + +[package.extras] +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] + +[[package]] +name = "pyparsing" +version = "3.0.7" +description = "Python parsing module" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + +[[package]] +name = "pytest" +version = "7.0.1" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +py = ">=1.8.2" +tomli = ">=1.0.0" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "3.0.0" +description = "Pytest plugin for measuring coverage." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] + +[[package]] +name = "ruamel.yaml" +version = "0.17.21" +description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" +category = "main" +optional = false +python-versions = ">=3" + +[package.dependencies] +"ruamel.yaml.clib" = {version = ">=0.2.6", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.11\""} + +[package.extras] +docs = ["ryd"] +jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] + +[[package]] +name = "ruamel.yaml.clib" +version = "0.2.6" +description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "tomli" +version = "1.2.3" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "typing-extensions" +version = "4.1.1" +description = "Backported and Experimental Type Hints for Python 3.6+" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "virtualenv" +version = "20.13.1" +description = "Virtual Python Environment builder" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[package.dependencies] +distlib = ">=0.3.1,<1" +filelock = ">=3.2,<4" +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +importlib-resources = {version = ">=1.0", markers = "python_version < \"3.7\""} +platformdirs = ">=2,<3" +six = ">=1.9.0,<2" + +[package.extras] +docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] +testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] + +[[package]] +name = "wcwidth" +version = "0.2.5" +description = "Measures the displayed width of unicode strings in a terminal" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "zipp" +version = "3.6.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] [metadata] lock-version = "1.1" -python-versions = "^3.6" -content-hash = "36970da0e8c6151dcf68abd9008ecef35673f04db53952bfb3fd7544c0516b7f" +python-versions = "^3.6.1" +content-hash = "01f4aaa2c8fbbf76fa7442a0c64bf553fe95fd3503fde730cad71c06da957a58" [metadata.files] -pyyaml = [ - {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"}, - {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"}, - {file = "PyYAML-5.4.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8"}, - {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"}, - {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"}, - {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"}, - {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"}, - {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541"}, - {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"}, - {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"}, - {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"}, - {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"}, - {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa"}, - {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"}, - {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"}, - {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"}, - {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"}, - {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"}, - {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247"}, - {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc"}, - {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"}, - {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"}, - {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"}, - {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"}, - {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"}, - {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"}, - {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"}, - {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, - {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, +atomicwrites = [ + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, +] +attrs = [ + {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, + {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, +] +click = [ + {file = "click-8.0.4-py3-none-any.whl", hash = "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1"}, + {file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"}, +] +colorama = [ + {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, + {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, +] +coverage = [ + {file = "coverage-6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6dbc1536e105adda7a6312c778f15aaabe583b0e9a0b0a324990334fd458c94b"}, + {file = "coverage-6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:174cf9b4bef0db2e8244f82059a5a72bd47e1d40e71c68ab055425172b16b7d0"}, + {file = "coverage-6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:92b8c845527eae547a2a6617d336adc56394050c3ed8a6918683646328fbb6da"}, + {file = "coverage-6.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c7912d1526299cb04c88288e148c6c87c0df600eca76efd99d84396cfe00ef1d"}, + {file = "coverage-6.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5d2033d5db1d58ae2d62f095e1aefb6988af65b4b12cb8987af409587cc0739"}, + {file = "coverage-6.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3feac4084291642165c3a0d9eaebedf19ffa505016c4d3db15bfe235718d4971"}, + {file = "coverage-6.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:276651978c94a8c5672ea60a2656e95a3cce2a3f31e9fb2d5ebd4c215d095840"}, + {file = "coverage-6.2-cp310-cp310-win32.whl", hash = "sha256:f506af4f27def639ba45789fa6fde45f9a217da0be05f8910458e4557eed020c"}, + {file = "coverage-6.2-cp310-cp310-win_amd64.whl", hash = "sha256:3f7c17209eef285c86f819ff04a6d4cbee9b33ef05cbcaae4c0b4e8e06b3ec8f"}, + {file = "coverage-6.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:13362889b2d46e8d9f97c421539c97c963e34031ab0cb89e8ca83a10cc71ac76"}, + {file = "coverage-6.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:22e60a3ca5acba37d1d4a2ee66e051f5b0e1b9ac950b5b0cf4aa5366eda41d47"}, + {file = "coverage-6.2-cp311-cp311-win_amd64.whl", hash = "sha256:b637c57fdb8be84e91fac60d9325a66a5981f8086c954ea2772efe28425eaf64"}, + {file = "coverage-6.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f467bbb837691ab5a8ca359199d3429a11a01e6dfb3d9dcc676dc035ca93c0a9"}, + {file = "coverage-6.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2641f803ee9f95b1f387f3e8f3bf28d83d9b69a39e9911e5bfee832bea75240d"}, + {file = "coverage-6.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1219d760ccfafc03c0822ae2e06e3b1248a8e6d1a70928966bafc6838d3c9e48"}, + {file = "coverage-6.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9a2b5b52be0a8626fcbffd7e689781bf8c2ac01613e77feda93d96184949a98e"}, + {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:8e2c35a4c1f269704e90888e56f794e2d9c0262fb0c1b1c8c4ee44d9b9e77b5d"}, + {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:5d6b09c972ce9200264c35a1d53d43ca55ef61836d9ec60f0d44273a31aa9f17"}, + {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e3db840a4dee542e37e09f30859f1612da90e1c5239a6a2498c473183a50e781"}, + {file = "coverage-6.2-cp36-cp36m-win32.whl", hash = "sha256:4e547122ca2d244f7c090fe3f4b5a5861255ff66b7ab6d98f44a0222aaf8671a"}, + {file = "coverage-6.2-cp36-cp36m-win_amd64.whl", hash = "sha256:01774a2c2c729619760320270e42cd9e797427ecfddd32c2a7b639cdc481f3c0"}, + {file = "coverage-6.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb8b8ee99b3fffe4fd86f4c81b35a6bf7e4462cba019997af2fe679365db0c49"}, + {file = "coverage-6.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:619346d57c7126ae49ac95b11b0dc8e36c1dd49d148477461bb66c8cf13bb521"}, + {file = "coverage-6.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0a7726f74ff63f41e95ed3a89fef002916c828bb5fcae83b505b49d81a066884"}, + {file = "coverage-6.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cfd9386c1d6f13b37e05a91a8583e802f8059bebfccde61a418c5808dea6bbfa"}, + {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:17e6c11038d4ed6e8af1407d9e89a2904d573be29d51515f14262d7f10ef0a64"}, + {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c254b03032d5a06de049ce8bca8338a5185f07fb76600afff3c161e053d88617"}, + {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dca38a21e4423f3edb821292e97cec7ad38086f84313462098568baedf4331f8"}, + {file = "coverage-6.2-cp37-cp37m-win32.whl", hash = "sha256:600617008aa82032ddeace2535626d1bc212dfff32b43989539deda63b3f36e4"}, + {file = "coverage-6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:bf154ba7ee2fd613eb541c2bc03d3d9ac667080a737449d1a3fb342740eb1a74"}, + {file = "coverage-6.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f9afb5b746781fc2abce26193d1c817b7eb0e11459510fba65d2bd77fe161d9e"}, + {file = "coverage-6.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edcada2e24ed68f019175c2b2af2a8b481d3d084798b8c20d15d34f5c733fa58"}, + {file = "coverage-6.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a9c8c4283e17690ff1a7427123ffb428ad6a52ed720d550e299e8291e33184dc"}, + {file = "coverage-6.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f614fc9956d76d8a88a88bb41ddc12709caa755666f580af3a688899721efecd"}, + {file = "coverage-6.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9365ed5cce5d0cf2c10afc6add145c5037d3148585b8ae0e77cc1efdd6aa2953"}, + {file = "coverage-6.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8bdfe9ff3a4ea37d17f172ac0dff1e1c383aec17a636b9b35906babc9f0f5475"}, + {file = "coverage-6.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:63c424e6f5b4ab1cf1e23a43b12f542b0ec2e54f99ec9f11b75382152981df57"}, + {file = "coverage-6.2-cp38-cp38-win32.whl", hash = "sha256:49dbff64961bc9bdd2289a2bda6a3a5a331964ba5497f694e2cbd540d656dc1c"}, + {file = "coverage-6.2-cp38-cp38-win_amd64.whl", hash = "sha256:9a29311bd6429be317c1f3fe4bc06c4c5ee45e2fa61b2a19d4d1d6111cb94af2"}, + {file = "coverage-6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03b20e52b7d31be571c9c06b74746746d4eb82fc260e594dc662ed48145e9efd"}, + {file = "coverage-6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:215f8afcc02a24c2d9a10d3790b21054b58d71f4b3c6f055d4bb1b15cecce685"}, + {file = "coverage-6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a4bdeb0a52d1d04123b41d90a4390b096f3ef38eee35e11f0b22c2d031222c6c"}, + {file = "coverage-6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c332d8f8d448ded473b97fefe4a0983265af21917d8b0cdcb8bb06b2afe632c3"}, + {file = "coverage-6.2-cp39-cp39-win32.whl", hash = "sha256:6e1394d24d5938e561fbeaa0cd3d356207579c28bd1792f25a068743f2d5b282"}, + {file = "coverage-6.2-cp39-cp39-win_amd64.whl", hash = "sha256:86f2e78b1eff847609b1ca8050c9e1fa3bd44ce755b2ec30e70f2d3ba3844644"}, + {file = "coverage-6.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:5829192582c0ec8ca4a2532407bc14c2f338d9878a10442f5d03804a95fac9de"}, + {file = "coverage-6.2.tar.gz", hash = "sha256:e2cad8093172b7d1595b4ad66f24270808658e11acf43a8f95b41276162eb5b8"}, +] +dataclasses = [ + {file = "dataclasses-0.8-py3-none-any.whl", hash = "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf"}, + {file = "dataclasses-0.8.tar.gz", hash = "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97"}, +] +distlib = [ + {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, + {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"}, +] +filelock = [ + {file = "filelock-3.4.1-py3-none-any.whl", hash = "sha256:a4bc51381e01502a30e9f06dd4fa19a1712eab852b6fb0f84fd7cce0793d8ca3"}, + {file = "filelock-3.4.1.tar.gz", hash = "sha256:0f12f552b42b5bf60dba233710bf71337d35494fc8bdd4fd6d9f6d082ad45e06"}, +] +importlib-metadata = [ + {file = "importlib_metadata-4.8.3-py3-none-any.whl", hash = "sha256:65a9576a5b2d58ca44d133c42a241905cc45e34d2c06fd5ba2bafa221e5d7b5e"}, + {file = "importlib_metadata-4.8.3.tar.gz", hash = "sha256:766abffff765960fcc18003801f7044eb6755ffae4521c8e8ce8e83b9c9b0668"}, +] +importlib-resources = [ + {file = "importlib_resources-5.4.0-py3-none-any.whl", hash = "sha256:33a95faed5fc19b4bc16b29a6eeae248a3fe69dd55d4d229d2b480e23eeaad45"}, + {file = "importlib_resources-5.4.0.tar.gz", hash = "sha256:d756e2f85dd4de2ba89be0b21dba2a3bbec2e871a42a3a16719258a11f87506b"}, +] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] +packaging = [ + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, +] +platformdirs = [ + {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"}, + {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"}, +] +pluggy = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] +py = [ + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +] +pydantic = [ + {file = "pydantic-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cb23bcc093697cdea2708baae4f9ba0e972960a835af22560f6ae4e7e47d33f5"}, + {file = "pydantic-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1d5278bd9f0eee04a44c712982343103bba63507480bfd2fc2790fa70cd64cf4"}, + {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab624700dc145aa809e6f3ec93fb8e7d0f99d9023b713f6a953637429b437d37"}, + {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8d7da6f1c1049eefb718d43d99ad73100c958a5367d30b9321b092771e96c25"}, + {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3c3b035103bd4e2e4a28da9da7ef2fa47b00ee4a9cf4f1a735214c1bcd05e0f6"}, + {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3011b975c973819883842c5ab925a4e4298dffccf7782c55ec3580ed17dc464c"}, + {file = "pydantic-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:086254884d10d3ba16da0588604ffdc5aab3f7f09557b998373e885c690dd398"}, + {file = "pydantic-1.9.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0fe476769acaa7fcddd17cadd172b156b53546ec3614a4d880e5d29ea5fbce65"}, + {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8e9dcf1ac499679aceedac7e7ca6d8641f0193c591a2d090282aaf8e9445a46"}, + {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1e4c28f30e767fd07f2ddc6f74f41f034d1dd6bc526cd59e63a82fe8bb9ef4c"}, + {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:c86229333cabaaa8c51cf971496f10318c4734cf7b641f08af0a6fbf17ca3054"}, + {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:c0727bda6e38144d464daec31dff936a82917f431d9c39c39c60a26567eae3ed"}, + {file = "pydantic-1.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:dee5ef83a76ac31ab0c78c10bd7d5437bfdb6358c95b91f1ba7ff7b76f9996a1"}, + {file = "pydantic-1.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d9c9bdb3af48e242838f9f6e6127de9be7063aad17b32215ccc36a09c5cf1070"}, + {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ee7e3209db1e468341ef41fe263eb655f67f5c5a76c924044314e139a1103a2"}, + {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b6037175234850ffd094ca77bf60fb54b08b5b22bc85865331dd3bda7a02fa1"}, + {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b2571db88c636d862b35090ccf92bf24004393f85c8870a37f42d9f23d13e032"}, + {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8b5ac0f1c83d31b324e57a273da59197c83d1bb18171e512908fe5dc7278a1d6"}, + {file = "pydantic-1.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:bbbc94d0c94dd80b3340fc4f04fd4d701f4b038ebad72c39693c794fd3bc2d9d"}, + {file = "pydantic-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e0896200b6a40197405af18828da49f067c2fa1f821491bc8f5bde241ef3f7d7"}, + {file = "pydantic-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bdfdadb5994b44bd5579cfa7c9b0e1b0e540c952d56f627eb227851cda9db77"}, + {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:574936363cd4b9eed8acdd6b80d0143162f2eb654d96cb3a8ee91d3e64bf4cf9"}, + {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c556695b699f648c58373b542534308922c46a1cda06ea47bc9ca45ef5b39ae6"}, + {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f947352c3434e8b937e3aa8f96f47bdfe6d92779e44bb3f41e4c213ba6a32145"}, + {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5e48ef4a8b8c066c4a31409d91d7ca372a774d0212da2787c0d32f8045b1e034"}, + {file = "pydantic-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:96f240bce182ca7fe045c76bcebfa0b0534a1bf402ed05914a6f1dadff91877f"}, + {file = "pydantic-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:815ddebb2792efd4bba5488bc8fde09c29e8ca3227d27cf1c6990fc830fd292b"}, + {file = "pydantic-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6c5b77947b9e85a54848343928b597b4f74fc364b70926b3c4441ff52620640c"}, + {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c68c3bc88dbda2a6805e9a142ce84782d3930f8fdd9655430d8576315ad97ce"}, + {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a79330f8571faf71bf93667d3ee054609816f10a259a109a0738dac983b23c3"}, + {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f5a64b64ddf4c99fe201ac2724daada8595ada0d102ab96d019c1555c2d6441d"}, + {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a733965f1a2b4090a5238d40d983dcd78f3ecea221c7af1497b845a9709c1721"}, + {file = "pydantic-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cc6a4cb8a118ffec2ca5fcb47afbacb4f16d0ab8b7350ddea5e8ef7bcc53a16"}, + {file = "pydantic-1.9.0-py3-none-any.whl", hash = "sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3"}, + {file = "pydantic-1.9.0.tar.gz", hash = "sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a"}, +] +pyparsing = [ + {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, + {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, +] +pytest = [ + {file = "pytest-7.0.1-py3-none-any.whl", hash = "sha256:9ce3ff477af913ecf6321fe337b93a2c0dcf2a0a1439c43f5452112c1e4280db"}, + {file = "pytest-7.0.1.tar.gz", hash = "sha256:e30905a0c131d3d94b89624a1cc5afec3e0ba2fbdb151867d8e0ebd49850f171"}, +] +pytest-cov = [ + {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, + {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, +] +"ruamel.yaml" = [ + {file = "ruamel.yaml-0.17.21-py3-none-any.whl", hash = "sha256:742b35d3d665023981bd6d16b3d24248ce5df75fdb4e2924e93a05c1f8b61ca7"}, + {file = "ruamel.yaml-0.17.21.tar.gz", hash = "sha256:8b7ce697a2f212752a35c1ac414471dc16c424c9573be4926b56ff3f5d23b7af"}, +] +"ruamel.yaml.clib" = [ + {file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:cfdb9389d888c5b74af297e51ce357b800dd844898af9d4a547ffc143fa56751"}, + {file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7b2927e92feb51d830f531de4ccb11b320255ee95e791022555971c466af4527"}, + {file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-win32.whl", hash = "sha256:ada3f400d9923a190ea8b59c8f60680c4ef8a4b0dfae134d2f2ff68429adfab5"}, + {file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-win_amd64.whl", hash = "sha256:de9c6b8a1ba52919ae919f3ae96abb72b994dd0350226e28f3686cb4f142165c"}, + {file = "ruamel.yaml.clib-0.2.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d67f273097c368265a7b81e152e07fb90ed395df6e552b9fa858c6d2c9f42502"}, + {file = "ruamel.yaml.clib-0.2.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:72a2b8b2ff0a627496aad76f37a652bcef400fd861721744201ef1b45199ab78"}, + {file = "ruamel.yaml.clib-0.2.6-cp36-cp36m-win32.whl", hash = "sha256:9efef4aab5353387b07f6b22ace0867032b900d8e91674b5d8ea9150db5cae94"}, + {file = "ruamel.yaml.clib-0.2.6-cp36-cp36m-win_amd64.whl", hash = "sha256:846fc8336443106fe23f9b6d6b8c14a53d38cef9a375149d61f99d78782ea468"}, + {file = "ruamel.yaml.clib-0.2.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0847201b767447fc33b9c235780d3aa90357d20dd6108b92be544427bea197dd"}, + {file = "ruamel.yaml.clib-0.2.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:78988ed190206672da0f5d50c61afef8f67daa718d614377dcd5e3ed85ab4a99"}, + {file = "ruamel.yaml.clib-0.2.6-cp37-cp37m-win32.whl", hash = "sha256:a49e0161897901d1ac9c4a79984b8410f450565bbad64dbfcbf76152743a0cdb"}, + {file = "ruamel.yaml.clib-0.2.6-cp37-cp37m-win_amd64.whl", hash = "sha256:bf75d28fa071645c529b5474a550a44686821decebdd00e21127ef1fd566eabe"}, + {file = "ruamel.yaml.clib-0.2.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a32f8d81ea0c6173ab1b3da956869114cae53ba1e9f72374032e33ba3118c233"}, + {file = "ruamel.yaml.clib-0.2.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7f7ecb53ae6848f959db6ae93bdff1740e651809780822270eab111500842a84"}, + {file = "ruamel.yaml.clib-0.2.6-cp38-cp38-win32.whl", hash = "sha256:89221ec6d6026f8ae859c09b9718799fea22c0e8da8b766b0b2c9a9ba2db326b"}, + {file = "ruamel.yaml.clib-0.2.6-cp38-cp38-win_amd64.whl", hash = "sha256:31ea73e564a7b5fbbe8188ab8b334393e06d997914a4e184975348f204790277"}, + {file = "ruamel.yaml.clib-0.2.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dc6a613d6c74eef5a14a214d433d06291526145431c3b964f5e16529b1842bed"}, + {file = "ruamel.yaml.clib-0.2.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:1866cf2c284a03b9524a5cc00daca56d80057c5ce3cdc86a52020f4c720856f0"}, + {file = "ruamel.yaml.clib-0.2.6-cp39-cp39-win32.whl", hash = "sha256:3fb9575a5acd13031c57a62cc7823e5d2ff8bc3835ba4d94b921b4e6ee664104"}, + {file = "ruamel.yaml.clib-0.2.6-cp39-cp39-win_amd64.whl", hash = "sha256:825d5fccef6da42f3c8eccd4281af399f21c02b32d98e113dbc631ea6a6ecbc7"}, + {file = "ruamel.yaml.clib-0.2.6.tar.gz", hash = "sha256:4ff604ce439abb20794f05613c374759ce10e3595d1867764dd1ae675b85acbd"}, +] +six = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] +toml = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] +tomli = [ + {file = "tomli-1.2.3-py3-none-any.whl", hash = "sha256:e3069e4be3ead9668e21cb9b074cd948f7b3113fd9c8bba083f48247aab8b11c"}, + {file = "tomli-1.2.3.tar.gz", hash = "sha256:05b6166bff487dc068d322585c7ea4ef78deed501cc124060e0f238e89a9231f"}, +] +typing-extensions = [ + {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"}, + {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"}, +] +virtualenv = [ + {file = "virtualenv-20.13.1-py2.py3-none-any.whl", hash = "sha256:45e1d053cad4cd453181ae877c4ffc053546ae99e7dd049b9ff1d9be7491abf7"}, + {file = "virtualenv-20.13.1.tar.gz", hash = "sha256:e0621bcbf4160e4e1030f05065c8834b4e93f4fcc223255db2a823440aca9c14"}, +] +wcwidth = [ + {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, + {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, +] +zipp = [ + {file = "zipp-3.6.0-py3-none-any.whl", hash = "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"}, + {file = "zipp-3.6.0.tar.gz", hash = "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832"}, ] diff --git a/pyproject.toml b/pyproject.toml index 9e5d4ca..7407cf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,22 @@ [tool.poetry] name = "kiwi-scp" -version = "0.1.7" +version = "0.2.0" description = "kiwi is the simple tool for managing container servers." authors = ["ldericher <40151420+ldericher@users.noreply.github.com>"] [tool.poetry.dependencies] -python = "^3.6" -PyYAML = "^5.4.1" +python = "^3.6.1" +attrs = "^21.2.0" +click = "^8.0.3" +pydantic = "^1.8.2" +"ruamel.yaml" = "^0.17.16" +wcwidth = "^0.2.5" [tool.poetry.dev-dependencies] +pytest = "^7.0.0" +pytest-cov = "^3.0.0" +toml = "^0.10.2" +virtualenv = "^20.10.0" [tool.poetry.scripts] kiwi = "kiwi_scp.scripts.kiwi:main" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..cb3bd90 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,491 @@ +from ipaddress import IPv4Network +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from kiwi_scp.config import KiwiConfig +from kiwi_scp.yaml import YAML + + +class UnCoercibleError(ValueError): + pass + + +class UnCoercible: + """A class that doesn't have a string representation""" + + def __str__(self): + raise UnCoercibleError() + + def __repr__(self) -> str: + return "UnCoercible()" + + +class TestDefault: + def test(self): + import toml + + c = KiwiConfig() + version = toml.load("./pyproject.toml")["tool"]["poetry"]["version"] + + assert c == KiwiConfig.from_default() + + assert c.version == version + assert len(c.shells) == 1 + assert c.shells[0] == Path("/bin/bash") + assert c.projects == [] + assert c.environment == {} + assert c.storage.directory == Path("/var/local/kiwi") + assert c.network.name == "kiwi_hub" + assert c.network.cidr == IPv4Network("10.22.46.0/24") + + kiwi_dict = { + "version": version, + "shells": ["/bin/bash"], + "storage": {"directory": "/var/local/kiwi"}, + "network": { + "name": "kiwi_hub", + "cidr": "10.22.46.0/24", + }, + } + assert c.kiwi_dict == kiwi_dict + + assert c.kiwi_yml == YAML().dump_kiwi_yml(kiwi_dict) + + +class TestVersion: + def test_valid(self): + c = KiwiConfig(version="0.0.0") + assert c.version == "0.0.0" + + c = KiwiConfig(version="0.0") + assert c.version == "0.0" + + c = KiwiConfig(version="0") + assert c.version == "0" + + c = KiwiConfig(version=1.0) + assert c.version == "1.0" + + c = KiwiConfig(version=1) + assert c.version == "1" + + def test_invalid(self): + # definitely not a version + with pytest.raises(ValidationError) as exc_info: + KiwiConfig(version="dnaf") + + assert len(exc_info.value.errors()) == 1 + error = exc_info.value.errors()[0] + assert error["msg"].find("string does not match regex") != -1 + assert error["type"] == "value_error.str.regex" + + # almost a version + with pytest.raises(ValidationError) as exc_info: + c = KiwiConfig(version="0.0.0alpha") + print(c.version) + + assert len(exc_info.value.errors()) == 1 + error = exc_info.value.errors()[0] + assert error["msg"].find("string does not match regex") != -1 + assert error["type"] == "value_error.str.regex" + + +class TestShells: + def test_empty(self): + c = KiwiConfig(shells=None) + + assert c == KiwiConfig(shells=[]) + + assert c.shells == [] + + def test_list(self): + c = KiwiConfig(shells=["/bin/sh", "sh"]) + + assert len(c.shells) == 2 + assert c.shells[0] == Path("/bin/sh") + assert c.shells[1] == Path("sh") + + c = KiwiConfig(shells=["/bin/bash"]) + + assert len(c.shells) == 1 + assert c.shells[0] == Path("/bin/bash") + + def test_dict(self): + c = KiwiConfig(shells={"/bin/bash": None}) + + assert len(c.shells) == 1 + assert c.shells[0] == Path("/bin/bash") + + def test_coercible(self): + c = KiwiConfig(shells="/bin/bash") + + assert c == KiwiConfig(shells=Path("/bin/bash")) + + assert len(c.shells) == 1 + assert c.shells[0] == Path("/bin/bash") + + c = KiwiConfig(shells=123) + + assert len(c.shells) == 1 + assert c.shells[0] == Path("123") + + def test_uncoercible(self): + with pytest.raises(ValidationError) as exc_info: + KiwiConfig(shells=UnCoercible()) + + assert len(exc_info.value.errors()) == 1 + error = exc_info.value.errors()[0] + assert error["msg"] == "Invalid 'KiwiConfig'.'shells' Format: UnCoercible()" + assert error["type"] == "value_error.invalidformat" + + with pytest.raises(ValidationError) as exc_info: + KiwiConfig(shells=["/bin/bash", UnCoercible()]) + + assert len(exc_info.value.errors()) == 1 + error = exc_info.value.errors()[0] + assert error["msg"] == "value is not a valid path" + assert error["type"] == "type_error.path" + + +class TestProject: + def test_empty(self): + c = KiwiConfig(projects=None) + + assert c == KiwiConfig(projects=[]) + assert c.projects == [] + + assert c.get_project_config("invalid") is None + + def test_long(self): + kiwi_dict = { + "name": "project", + "enabled": False, + "override_storage": {"directory": "/test/directory"}, + } + c = KiwiConfig(projects=[kiwi_dict]) + + assert len(c.projects) == 1 + p = c.projects[0] + assert p.name == "project" + assert p == c.get_project_config("project") + assert not p.enabled + assert p.override_storage is not None + + assert c.kiwi_dict["projects"][0] == kiwi_dict + + def test_storage_str(self): + kiwi_dict = { + "name": "project", + "enabled": False, + "override_storage": "/test/directory", + } + c = KiwiConfig(projects=[kiwi_dict]) + + assert len(c.projects) == 1 + p = c.projects[0] + assert p.name == "project" + assert not p.enabled + assert p.override_storage is not None + + def test_storage_list(self): + kiwi_dict = { + "name": "project", + "enabled": False, + "override_storage": ["/test/directory"], + } + c = KiwiConfig(projects=[kiwi_dict]) + + assert len(c.projects) == 1 + p = c.projects[0] + assert p.name == "project" + assert not p.enabled + assert p.override_storage is not None + + def test_storage_invalid(self): + kiwi_dict = { + "name": "project", + "enabled": False, + "override_storage": True, + } + with pytest.raises(ValidationError) as exc_info: + KiwiConfig(projects=[kiwi_dict]) + + assert len(exc_info.value.errors()) == 1 + error = exc_info.value.errors()[0] + assert error["msg"] == "Invalid 'StorageConfig' Format: '{}'" + assert error["type"] == "value_error.invalidformat" + + def test_short(self): + kiwi_dict = { + "project": False, + } + c = KiwiConfig(projects=[kiwi_dict]) + + assert len(c.projects) == 1 + p = c.projects[0] + assert p.name == "project" + assert not p.enabled + assert p.override_storage is None + + resulting_kiwi_dict = { + "name": "project", + "enabled": False, + } + assert p.kiwi_dict == resulting_kiwi_dict + + def test_dict(self): + c = KiwiConfig(projects={"name": "project"}) + + assert c == KiwiConfig(projects=[{"name": "project"}]) + + assert len(c.projects) == 1 + p = c.projects[0] + assert p.name == "project" + assert p.enabled + assert p.override_storage is None + + def test_reserved_name(self): + with pytest.raises(ValidationError) as exc_info: + KiwiConfig(projects={"name": "config"}) + + assert len(exc_info.value.errors()) == 1 + error = exc_info.value.errors()[0] + assert error["msg"] == "Project name 'config' is reserved!" + assert error["type"] == "value_error.projectnamereserved" + + def test_invalid_dict(self): + with pytest.raises(ValidationError) as exc_info: + KiwiConfig(projects={ + "random key 1": "random value 1", + "random key 2": "random value 2", + }) + + assert len(exc_info.value.errors()) == 1 + error = exc_info.value.errors()[0] + assert error["msg"] == "Invalid 'ProjectConfig' Format: " \ + "{'random key 1': 'random value 1', 'random key 2': 'random value 2'}" + assert error["type"] == "value_error.invalidformat" + + def test_coercible(self): + c = KiwiConfig(projects="project") + + assert c == KiwiConfig(projects=["project"]) + + assert len(c.projects) == 1 + p = c.projects[0] + assert p.name == "project" + assert p.enabled + assert p.override_storage is None + + def test_uncoercible(self): + with pytest.raises(ValidationError) as exc_info: + KiwiConfig(projects=["valid", UnCoercible()]) + + assert len(exc_info.value.errors()) == 1 + error = exc_info.value.errors()[0] + assert error["msg"] == "Invalid 'KiwiConfig'.'projects' Format: ['valid', UnCoercible()]" + assert error["type"] == "value_error.invalidformat" + + with pytest.raises(ValidationError) as exc_info: + KiwiConfig(projects=UnCoercible()) + + assert len(exc_info.value.errors()) == 1 + error = exc_info.value.errors()[0] + assert error["msg"] == "Invalid 'KiwiConfig'.'projects' Format: UnCoercible()" + assert error["type"] == "value_error.invalidformat" + + +class TestEnvironment: + def test_empty(self): + c = KiwiConfig(environment=None) + + assert c.environment == {} + + def test_dict(self): + c = KiwiConfig(environment={}) + + assert c.environment == {} + + kiwi_dict = {"variable": "value"} + c = KiwiConfig(environment=kiwi_dict) + + assert len(c.environment) == 1 + assert "variable" in c.environment + assert c.environment["variable"] == "value" + + assert c.kiwi_dict["environment"] == kiwi_dict + + def test_list(self): + c = KiwiConfig(environment=[]) + + assert c.environment == {} + + c = KiwiConfig(environment=[ + "variable=value", + ]) + + assert len(c.environment) == 1 + assert "variable" in c.environment + assert c.environment["variable"] == "value" + + c = KiwiConfig(environment=[ + "variable", + ]) + + assert len(c.environment) == 1 + assert "variable" in c.environment + assert c.environment["variable"] is None + + c = KiwiConfig(environment=[ + 123, + ]) + + assert len(c.environment) == 1 + assert "123" in c.environment + assert c.environment["123"] is None + + def test_coercible(self): + c = KiwiConfig(environment="variable=value") + + assert len(c.environment) == 1 + assert "variable" in c.environment + assert c.environment["variable"] == "value" + + c = KiwiConfig(environment="variable") + + assert len(c.environment) == 1 + assert "variable" in c.environment + assert c.environment["variable"] is None + + c = KiwiConfig(environment=123) + + assert len(c.environment) == 1 + assert "123" in c.environment + assert c.environment["123"] is None + + c = KiwiConfig(environment=123.4) + + assert len(c.environment) == 1 + assert "123.4" in c.environment + assert c.environment["123.4"] is None + + def test_uncoercible(self): + with pytest.raises(ValidationError) as exc_info: + KiwiConfig(environment=UnCoercible()) + + assert len(exc_info.value.errors()) == 1 + error = exc_info.value.errors()[0] + assert error["msg"] == "Invalid 'KiwiConfig'.'environment' Format: UnCoercible()" + assert error["type"] == "value_error.invalidformat" + + with pytest.raises(ValidationError) as exc_info: + KiwiConfig(environment=["valid", UnCoercible()]) + + assert len(exc_info.value.errors()) == 1 + error = exc_info.value.errors()[0] + assert error["msg"] == "Invalid 'KiwiConfig'.'environment' Format: None" + assert error["type"] == "value_error.invalidformat" + + +class TestStorage: + def test_empty(self): + with pytest.raises(ValidationError) as exc_info: + KiwiConfig(storage=None) + + assert len(exc_info.value.errors()) == 1 + error = exc_info.value.errors()[0] + assert error["msg"] == "Member 'KiwiConfig'.'storage' is required!" + assert error["type"] == "value_error.missingmember" + + def test_dict(self): + kiwi_dict = {"directory": "/test/directory"} + c = KiwiConfig(storage=kiwi_dict) + + assert c.storage.directory == Path("/test/directory") + assert c.storage.kiwi_dict == kiwi_dict + + def test_invalid_dict(self): + with pytest.raises(ValidationError) as exc_info: + KiwiConfig(storage={"random key": "random value"}) + + assert len(exc_info.value.errors()) == 1 + error = exc_info.value.errors()[0] + assert error["msg"] == "Invalid 'StorageConfig' Format: \"{'random key': 'random value'}\"" + assert error["type"] == "value_error.invalidformat" + + def test_str(self): + c = KiwiConfig(storage="/test/directory") + + assert c.storage.directory == Path("/test/directory") + + def test_list(self): + c = KiwiConfig(storage=["/test/directory"]) + + assert c.storage.directory == Path("/test/directory") + + def test_invalid(self): + with pytest.raises(ValidationError) as exc_info: + KiwiConfig(storage=True) + + assert len(exc_info.value.errors()) == 1 + error = exc_info.value.errors()[0] + assert error["msg"] == "Invalid 'StorageConfig' Format: '{}'" + assert error["type"] == "value_error.invalidformat" + + +class TestNetwork: + def test_empty(self): + with pytest.raises(ValidationError) as exc_info: + KiwiConfig(network=None) + + assert len(exc_info.value.errors()) == 1 + error = exc_info.value.errors()[0] + assert error["msg"] == "Member 'KiwiConfig'.'network' is required!" + assert error["type"] == "value_error.missingmember" + + def test_dict(self): + kiwi_dict = { + "name": "test_hub", + "cidr": "1.2.3.4/32", + } + c = KiwiConfig(network=kiwi_dict) + + assert c == KiwiConfig(network={ + "name": "TEST_HUB", + "cidr": "1.2.3.4/32", + }) + + assert c.network.name == "test_hub" + assert c.network.cidr == IPv4Network("1.2.3.4/32") + assert c.network.kiwi_dict == kiwi_dict + + def test_invalid_dict(self): + with pytest.raises(ValidationError) as exc_info: + KiwiConfig(network={"name": "test_hub"}) + + assert len(exc_info.value.errors()) == 1 + error = exc_info.value.errors()[0] + assert error["msg"] == "field required" + assert error["type"] == "value_error.missing" + + with pytest.raises(ValidationError) as exc_info: + KiwiConfig(network={ + "name": "test_hub", + "cidr": "1.2.3.4/123", + }) + + assert len(exc_info.value.errors()) == 1 + error = exc_info.value.errors()[0] + assert error["msg"] == "value is not a valid IPv4 network" + assert error["type"] == "value_error.ipv4network" + + def test_invalid(self): + with pytest.raises(ValidationError) as exc_info: + KiwiConfig(network=True) + + assert len(exc_info.value.errors()) == 1 + error = exc_info.value.errors()[0] + assert error["msg"] == "Invalid 'KiwiConfig'.'network' Format: True" + assert error["type"] == "value_error.invalidformat" diff --git a/tests/test_instance.py b/tests/test_instance.py new file mode 100644 index 0000000..4b7f9a5 --- /dev/null +++ b/tests/test_instance.py @@ -0,0 +1,29 @@ +from pathlib import Path + +from kiwi_scp.instance import Instance + + +class TestDefault: + def test_example(self): + i = Instance(Path("example")) + + assert i.config is not None + assert len(i.config.projects) == 1 + + pc = i.config.projects[0] + + assert pc.name == "hello_world" + + def test_empty(self): + i = Instance() + + assert i.config is not None + assert len(i.config.projects) == 0 + + def test_no_such_dir(self): + nonexistent_path = Path("nonexistent") + i = Instance(nonexistent_path) + + assert i.directory == nonexistent_path + assert i.config is not None + assert len(i.config.projects) == 0 diff --git a/tests/test_project.py b/tests/test_project.py new file mode 100644 index 0000000..8234e32 --- /dev/null +++ b/tests/test_project.py @@ -0,0 +1,40 @@ +from pathlib import Path + +import pytest + +from kiwi_scp._constants import COMPOSE_FILE_NAME +from kiwi_scp.config import KiwiConfig +from kiwi_scp.project import Project + + +class TestDefault: + cfg = KiwiConfig() + + def test_example(self): + p = Project( + directory=Path("example/hello_world"), + parent_instance=None, + ) + + ss = p.services + + assert len(ss.content) == 5 + + s = ss.content[0] + + assert s.name == "greeter" + + ss2 = p.services.filter_existing(["nonexistent"]) + + assert len(ss2.content) == 0 + + def test_empty(self): + p = Project( + directory=Path("nonexistent"), + parent_instance=None, + ) + + with pytest.raises(FileNotFoundError) as exc_info: + _ = p.services + + assert exc_info.value.filename == f"nonexistent/{COMPOSE_FILE_NAME}" diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..064edad --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,66 @@ +from pathlib import Path + +from ruamel.yaml import CommentedMap + +from kiwi_scp.service import Service + + +class TestDefault: + def test_empty(self): + s = Service( + name="s", + content=CommentedMap(), + parent_project=None, + ) + + assert s.name == "s" + assert list(s.configs) == [] + + def test_no_configs(self): + s = Service( + name="s", + content=CommentedMap({ + "image": "repo/image:tag", + }), + parent_project=None, + ) + + assert s.name == "s" + assert list(s.configs) == [] + + def test_no_configs_in_volumes(self): + s = Service( + name="s", + content=CommentedMap({ + "image": "repo/image:tag", + "volumes": [ + "docker_volume/third/dir:/path/to/third/mountpoint", + "${TARGETDIR}/some/dir:/path/to/some/mountpoint", + "$TARGETDIR/other/dir:/path/to/other/mountpoint", + ] + }), + parent_project=None, + ) + + assert s.name == "s" + assert list(s.configs) == [] + + def test_with_configs(self): + s = Service( + name="s", + content=CommentedMap({ + "image": "repo/image:tag", + "volumes": [ + "${CONFIGDIR}/some/config:/path/to/some/config", + "$CONFIGDIR/other/config:/path/to/other/config", + ] + }), + parent_project=None, + ) + + assert s.name == "s" + assert len(list(s.configs)) == 2 + assert list(s.configs) == [ + Path("some/config"), + Path("other/config"), + ] diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..cfb7716 --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,16 @@ +from ruamel.yaml import CommentedMap + +from kiwi_scp.service import Service +from kiwi_scp.services import Services + + +class TestServices: + def test_empty(self): + s = Service( + name="s", + content=CommentedMap(), + parent_project=None, + ) + ss = Services([s]) + + assert str(ss) == "services:\n s: {}\nconfigs: []"