Merge branch 'release/0.2.0'

This commit is contained in:
Jörn-Michael Miehe 2022-02-22 13:25:04 +01:00
commit 2231dfb612
85 changed files with 3462 additions and 1733 deletions

10
.dockerignore Normal file
View file

@ -0,0 +1,10 @@
.git/
.idea/
dist/
example/
Dockerfile
.dockerignore
.gitignore
.drone.yml

View file

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

2
.gitignore vendored
View file

@ -1 +1,3 @@
__pycache__/
htmlcov/
.coverage

View file

@ -4,6 +4,8 @@
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/src/venv" />
<excludeFolder url="file://$MODULE_DIR$/htmlcov" />
<excludePattern pattern=".coverage" />
</content>
<orderEntry type="jdk" jdkName="Poetry (kiwi-scp)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />

View file

@ -0,0 +1,18 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Test Coverage" type="tests" factoryName="py.test">
<module name="kiwi-scp" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="_new_keywords" value="&quot;&quot;" />
<option name="_new_parameters" value="&quot;&quot;" />
<option name="_new_additionalArguments" value="&quot;--cov\u003dkiwi_scp --cov-report\u003dhtml&quot;" />
<option name="_new_target" value="&quot;tests&quot;" />
<option name="_new_targetType" value="&quot;PYTHON&quot;" />
<method v="2" />
</configuration>
</component>

View file

@ -0,0 +1,18 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Tests (Debuggable)" type="tests" factoryName="py.test">
<module name="kiwi-scp" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="_new_keywords" value="&quot;&quot;" />
<option name="_new_parameters" value="&quot;&quot;" />
<option name="_new_additionalArguments" value="&quot;&quot;" />
<option name="_new_target" value="&quot;tests&quot;" />
<option name="_new_targetType" value="&quot;PYTHON&quot;" />
<method v="2" />
</configuration>
</component>

View file

@ -0,0 +1,23 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="kiwi" type="PythonConfigurationType" factoryName="Python">
<module name="kiwi-scp" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/example" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="kiwi_scp.scripts.kiwi" />
<option name="PARAMETERS" value="-v shell hello-world.project greeter" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="true" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View file

@ -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"]

123
README.md
View file

@ -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 <project-name>`.
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 <project-name>`.
@ -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.
##### `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"`
<font size="5">**For everything else, look at `kiwi --help`**
**Happy kiwi-ing!**</font>
[^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.

View file

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

13
dist/bump-version.sh vendored Executable file
View file

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

View file

20
kiwi → dist/kiwi vendored
View file

@ -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

View file

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

View file

@ -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

View file

@ -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'
]

View file

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

View file

@ -0,0 +1,3 @@
from .cli import KiwiCLI
__all__ = ["KiwiCLI"]

95
kiwi_scp/commands/cli.py Normal file
View file

@ -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)

184
kiwi_scp/commands/cmd.py Normal file
View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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))

View file

@ -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)

View file

@ -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)

View file

@ -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!")

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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>
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 "<variable>=<value>" 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):
# "<var>=<value>"
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")

View file

@ -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

View file

@ -10,6 +10,10 @@ networks:
name: ${KIWI_HUB_NAME}
services:
######################
# START EDITING HERE #
######################
# an example service
something:
# uses an image

View file

@ -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

View file

@ -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

View file

@ -1 +0,0 @@
0.1.7

View file

@ -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")

108
kiwi_scp/instance.py Normal file
View file

@ -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
}

View file

@ -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'

View file

@ -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)

View file

@ -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()
])

View file

@ -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

View file

@ -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)

View file

@ -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)

41
kiwi_scp/scripts/kiwi.py Executable file → Normal file
View file

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

61
kiwi_scp/service.py Normal file
View file

@ -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}'")

91
kiwi_scp/services.py Normal file
View file

@ -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
])

View file

@ -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)

View file

@ -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',
]

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

97
kiwi_scp/wstring.py Normal file
View file

@ -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
])

37
kiwi_scp/yaml.py Normal file
View file

@ -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)

571
poetry.lock generated
View file

@ -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"},
]

View file

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

0
tests/__init__.py Normal file
View file

491
tests/test_config.py Normal file
View file

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

29
tests/test_instance.py Normal file
View file

@ -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

40
tests/test_project.py Normal file
View file

@ -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}"

66
tests/test_service.py Normal file
View file

@ -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"),
]

16
tests/test_services.py Normal file
View file

@ -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: []"