mirror of
https://github.com/yavook/kiwi-scp.git
synced 2024-11-23 21:23:00 +00:00
Merge branch 'release/0.2.0'
This commit is contained in:
commit
db54955a49
85 changed files with 3473 additions and 1746 deletions
10
.dockerignore
Normal file
10
.dockerignore
Normal file
|
@ -0,0 +1,10 @@
|
|||
.git/
|
||||
.idea/
|
||||
|
||||
dist/
|
||||
example/
|
||||
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.gitignore
|
||||
.drone.yml
|
|
@ -3,6 +3,14 @@ kind: pipeline
|
|||
name: default
|
||||
|
||||
steps:
|
||||
- name: pytest
|
||||
image: python:3.6-alpine3.13
|
||||
commands:
|
||||
- apk add --no-cache g++ libffi-dev
|
||||
- wget -O- https://raw.githubusercontent.com/python-poetry/poetry/master/install-poetry.py | python3 -
|
||||
- /root/.local/bin/poetry install
|
||||
- /root/.local/bin/poetry run pytest
|
||||
|
||||
- name: docker
|
||||
image: plugins/docker
|
||||
settings:
|
||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1 +1,3 @@
|
|||
__pycache__/
|
||||
htmlcov/
|
||||
.coverage
|
|
@ -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" />
|
||||
|
|
18
.idea/runConfigurations/Test_Coverage.xml
Normal file
18
.idea/runConfigurations/Test_Coverage.xml
Normal 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="""" />
|
||||
<option name="_new_parameters" value="""" />
|
||||
<option name="_new_additionalArguments" value=""--cov\u003dkiwi_scp --cov-report\u003dhtml"" />
|
||||
<option name="_new_target" value=""tests"" />
|
||||
<option name="_new_targetType" value=""PYTHON"" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
18
.idea/runConfigurations/Tests__Debuggable_.xml
Normal file
18
.idea/runConfigurations/Tests__Debuggable_.xml
Normal 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="""" />
|
||||
<option name="_new_parameters" value="""" />
|
||||
<option name="_new_additionalArguments" value="""" />
|
||||
<option name="_new_target" value=""tests"" />
|
||||
<option name="_new_targetType" value=""PYTHON"" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
23
.idea/runConfigurations/kiwi.xml
Normal file
23
.idea/runConfigurations/kiwi.xml
Normal 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>
|
|
@ -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"]
|
||||
|
|
119
README.md
119
README.md
|
@ -10,7 +10,7 @@ The simple tool for managing container servers
|
|||
## Quick start
|
||||
|
||||
- Learn to use `docker` with `docker-compose`
|
||||
- Install kiwi-scp
|
||||
- Install `kiwi-scp`
|
||||
- Look at [the example instance](./example)
|
||||
- Look at the output of `kiwi --help`
|
||||
- Start building your own instances
|
||||
|
@ -18,24 +18,24 @@ The simple tool for managing container servers
|
|||
|
||||
## Installation
|
||||
|
||||
A convenience installer is available as [install.sh](./install.sh) in this directory.
|
||||
A convenience installer is available as [install.sh](./dist/install.sh) in the `dist` directory.
|
||||
You can `curl | sh` it using the following one-liner.
|
||||
|
||||
```shell script
|
||||
curl --proto '=https' --tlsv1.2 -sSf 'https://raw.githubusercontent.com/ldericher/kiwi-scp/master/install.sh' | sh
|
||||
curl --proto '=https' --tlsv1.2 -sSf 'https://raw.githubusercontent.com/ldericher/kiwi-scp/master/dist/install.sh' | sh
|
||||
```
|
||||
|
||||
The installer downloads the `kiwi` launcher script and installs it to a location of your choice.
|
||||
Please consider installing into a directory inside your `$PATH`.
|
||||
Run in a root shell or use `sudo sh` instead for system-wide installation.
|
||||
Run in a root shell or use `sudo sh` at the end instead for system-wide installation.
|
||||
|
||||
You should now be able to run `kiwi init --show` and see the default configuration file.
|
||||
This downloads the latest version of the main kiwi-scp executable and sets it up for you.
|
||||
You should then be able to run `kiwi list --show` and see the default configuration file.
|
||||
This installs the latest version of the kiwi-scp package and sets it up for you.
|
||||
|
||||
|
||||
### Adjusting environment for `kiwi`
|
||||
|
||||
The `kiwi` executable depends on [Python](https://www.python.org/) 3.6 (or later) and
|
||||
The `kiwi` executable depends on [Python](https://www.python.org/) 3.6.1 (or later) and
|
||||
[less](http://www.greenwoodsoftware.com/less/) being in your `$PATH`.
|
||||
|
||||
In some cases, notably when using a multi-version system such as
|
||||
|
@ -44,8 +44,7 @@ at login time.
|
|||
|
||||
In those cases, you can simply create a `.kiwi_profile` file in your home directory.
|
||||
It will be sourced every time you use the `kiwi` command.
|
||||
For the aforementioned case where you installed `centos-release-scl` and `rh-python36`, your `~/.kiwi_profile` should
|
||||
contain:
|
||||
For the aforementioned case where you installed `centos-release-scl` and `rh-python36`, your `~/.kiwi_profile` should contain:
|
||||
|
||||
```shell script
|
||||
#!/bin/sh
|
||||
|
@ -59,8 +58,7 @@ contain:
|
|||
### Create a kiwi-scp instance
|
||||
|
||||
Any directory is implicitly a valid kiwi-scp instance using the default configuration.
|
||||
To prevent surprises however, you should run `kiwi init` in an empty directory and follow its directions to
|
||||
create a `kiwi.yml` before using `kiwi` more.
|
||||
To prevent surprises however, you should run `kiwi init` and follow its directions to create a `kiwi.yml` for your instance before using `kiwi` more.
|
||||
|
||||
|
||||
### Concept
|
||||
|
@ -69,11 +67,11 @@ A kiwi-scp instance is a directory containing a bunch of static configuration fi
|
|||
"Static" there as in "those don't change during normal service operation".
|
||||
These files could be anything from actual `.conf` files to entire html-web-roots.
|
||||
|
||||
Non-static, but persistent files are to be kept in a "service data directory", by default `/var/kiwi`.
|
||||
In your `docker-compose.yml` files, you can refer to that directory as **${TARGETROOT}**.
|
||||
Non-static, persistent files are to be kept in a "service data storage", by default the directory `/var/local/kiwi`.
|
||||
In your `docker-compose.yml` files, you can refer to that directory as **${KIWI_INSTANCE}**.
|
||||
|
||||
Start the current directory as a kiwi-scp instance using `kiwi up`, or stop it using `kiwi down`.
|
||||
This also creates kiwi's internal hub network, which you can use as **kiwi_hub** in your `docker-compose.yml` files.
|
||||
Start the current kiwi-scp instance using `kiwi up`, or stop it using `kiwi down`.
|
||||
This also manages kiwi's internal hub network, which you can use as **kiwi_hub** in your `docker-compose.yml` files.
|
||||
|
||||
|
||||
### Projects
|
||||
|
@ -88,7 +86,7 @@ Before enabling or starting, consider editing the new project's `docker-compose.
|
|||
Finally, enable it with `kiwi enable <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:
|
||||
- name: "hello_world"
|
||||
enabled: true
|
||||
```
|
||||
|
||||
##### `environment`
|
||||
Custom variables available in projects' `docker-compose.yml` files.
|
||||
|
||||
Format[^1]: Mapping of `KEY: "value"` pairs
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
HELLO: "World"
|
||||
FOO: "Bar"
|
||||
```
|
||||
|
||||
#### For everything else, look at `kiwi --help`
|
||||
#### Happy kiwi-ing!
|
||||
##### `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.
|
||||
|
|
|
@ -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
13
dist/bump-version.sh
vendored
Executable 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"
|
2
install.sh → dist/install.sh
vendored
2
install.sh → dist/install.sh
vendored
|
@ -7,7 +7,7 @@
|
|||
# default installation directory
|
||||
INSTALL_DIR_DEFAULT="/usr/local/sbin"
|
||||
# URI of "kiwi" launcher script
|
||||
KIWI_URI="https://raw.githubusercontent.com/ldericher/kiwi-scp/master/kiwi"
|
||||
KIWI_URI="https://raw.githubusercontent.com/ldericher/kiwi-scp/master/dist/kiwi"
|
||||
|
||||
#############
|
||||
# FUNCTIONS #
|
34
kiwi → dist/kiwi
vendored
34
kiwi → dist/kiwi
vendored
|
@ -6,8 +6,6 @@
|
|||
|
||||
# base config filename
|
||||
KIWI_CONF_NAME="kiwi.yml"
|
||||
# version tag filename
|
||||
KIWI_VERSION_TAG="kiwi_scp/data/etc/version_tag"
|
||||
|
||||
# dependencies to run kiwi-scp
|
||||
KIWI_DEPENDENCIES="python3 less docker docker-compose"
|
||||
|
@ -18,12 +16,14 @@ KIWI_PROFILE="${HOME}/.kiwi_profile"
|
|||
|
||||
# repository uri
|
||||
KIWI_REPO="https://github.com/ldericher/kiwi-scp"
|
||||
KIWI_REPO_RAW="https://raw.githubusercontent.com/ldericher/kiwi-scp"
|
||||
# use latest version by default
|
||||
KIWI_VERSION="master"
|
||||
|
||||
# URI of "kiwi" launcher script
|
||||
KIWI_URI="https://raw.githubusercontent.com/ldericher/kiwi-scp/master/kiwi"
|
||||
INSTALLER_URI="https://raw.githubusercontent.com/ldericher/kiwi-scp/master/install.sh"
|
||||
# URIs in this directory
|
||||
PACKAGE_URI="pyproject.toml"
|
||||
KIWI_URI="dist/kiwi"
|
||||
INSTALLER_URI="dist/install.sh"
|
||||
# canary file: limit curl requests
|
||||
CANARY_FILENAME="/tmp/kiwi-scp-$(id -u).canary"
|
||||
CANARY_MAX_AGE=600
|
||||
|
@ -115,14 +115,14 @@ fi
|
|||
if [ "${run_kiwi_check}" = "yes" ]; then
|
||||
# hash this script and the master version
|
||||
hash_local="$(md5sum <"$(readlink -f "${0}")")"
|
||||
hash_remote="$(curl --proto '=https' --tlsv1.2 -sSfL "${KIWI_URI}" | md5sum)"
|
||||
hash_remote="$(curl --proto '=https' --tlsv1.2 -sSfL "${KIWI_REPO_RAW}/${KIWI_VERSION}/${KIWI_URI}" | md5sum)"
|
||||
|
||||
# warn if different
|
||||
if [ "${hash_local}" != "${hash_remote}" ]; then
|
||||
|
||||
if yes_no "Your kiwi launcher is outdated. Update now?" >/dev/stderr; then
|
||||
# should reinstall, so download installer
|
||||
installer="$(curl --proto '=https' --tlsv1.2 -sSfL "${INSTALLER_URI}")"
|
||||
installer="$(curl --proto '=https' --tlsv1.2 -sSfL "${KIWI_REPO_RAW}/${KIWI_VERSION}/${INSTALLER_URI}")"
|
||||
|
||||
if yes_no "Use sudo to run as root?"; then
|
||||
# enable system-wide install
|
||||
|
@ -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
|
||||
# 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
|
||||
|
@ -169,11 +177,9 @@ if [ ! -x "$(kiwi_executable)" ]; then
|
|||
|
||||
# read version tag
|
||||
KIWI_VERSION="$( \
|
||||
curl \
|
||||
--proto '=https' \
|
||||
--tlsv1.2 \
|
||||
-sSfL \
|
||||
"https://raw.githubusercontent.com/ldericher/kiwi-scp/${KIWI_VERSION}/${KIWI_VERSION_TAG}" \
|
||||
curl --proto '=https' --tlsv1.2 -sSfL "${KIWI_REPO_RAW}/${KIWI_VERSION}/${PACKAGE_URI}" \
|
||||
| grep -r 'version\s*=' \
|
||||
| sed -r "s/version\s*=\s*\"([^\"]*)\"$/\1/" \
|
||||
)"
|
||||
|
||||
if [ -x "$(kiwi_executable)" ]; then
|
|
@ -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"
|
|
@ -2,17 +2,17 @@
|
|||
# kiwi-scp instance configuration #
|
||||
###################################
|
||||
|
||||
version: '0.1.7'
|
||||
version: 0.2.0
|
||||
|
||||
runtime:
|
||||
storage: /tmp/kiwi
|
||||
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
|
||||
|
|
|
@ -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'
|
||||
]
|
|
@ -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"
|
||||
|
|
3
kiwi_scp/commands/__init__.py
Normal file
3
kiwi_scp/commands/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from .cli import KiwiCLI
|
||||
|
||||
__all__ = ["KiwiCLI"]
|
95
kiwi_scp/commands/cli.py
Normal file
95
kiwi_scp/commands/cli.py
Normal 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
184
kiwi_scp/commands/cmd.py
Normal 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()
|
21
kiwi_scp/commands/cmd_build.py
Normal file
21
kiwi_scp/commands/cmd_build.py
Normal 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)
|
36
kiwi_scp/commands/cmd_cmd.py
Normal file
36
kiwi_scp/commands/cmd_cmd.py
Normal 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)
|
40
kiwi_scp/commands/cmd_disable.py
Normal file
40
kiwi_scp/commands/cmd_disable.py
Normal 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)
|
57
kiwi_scp/commands/cmd_down.py
Normal file
57
kiwi_scp/commands/cmd_down.py
Normal 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)
|
40
kiwi_scp/commands/cmd_enable.py
Normal file
40
kiwi_scp/commands/cmd_enable.py
Normal 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)
|
71
kiwi_scp/commands/cmd_init.py
Normal file
71
kiwi_scp/commands/cmd_init.py
Normal 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))
|
59
kiwi_scp/commands/cmd_list.py
Normal file
59
kiwi_scp/commands/cmd_list.py
Normal 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)
|
44
kiwi_scp/commands/cmd_logs.py
Normal file
44
kiwi_scp/commands/cmd_logs.py
Normal 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)
|
41
kiwi_scp/commands/cmd_new.py
Normal file
41
kiwi_scp/commands/cmd_new.py
Normal 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!")
|
33
kiwi_scp/commands/cmd_pull.py
Normal file
33
kiwi_scp/commands/cmd_pull.py
Normal 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)
|
33
kiwi_scp/commands/cmd_push.py
Normal file
33
kiwi_scp/commands/cmd_push.py
Normal 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)
|
48
kiwi_scp/commands/cmd_restart.py
Normal file
48
kiwi_scp/commands/cmd_restart.py
Normal 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)
|
73
kiwi_scp/commands/cmd_shell.py
Normal file
73
kiwi_scp/commands/cmd_shell.py
Normal 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)
|
34
kiwi_scp/commands/cmd_up.py
Normal file
34
kiwi_scp/commands/cmd_up.py
Normal 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)
|
65
kiwi_scp/commands/cmd_update.py
Normal file
65
kiwi_scp/commands/cmd_update.py
Normal 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)
|
83
kiwi_scp/commands/decorators.py
Normal file
83
kiwi_scp/commands/decorators.py
Normal 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
|
|
@ -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:
|
||||
"""represents a kiwi.yml"""
|
||||
class InvalidFormatError(ValueError):
|
||||
"""raised if format recognition unsuccessful"""
|
||||
|
||||
__yml_content = {}
|
||||
__keys = {
|
||||
'version': "kiwi-scp version to use in this instance",
|
||||
cls: type
|
||||
member: Optional[str]
|
||||
data: str
|
||||
|
||||
'runtime:storage': "local directory for service data",
|
||||
'runtime:shells': "shell preference for working in service containers",
|
||||
'runtime:env': "common environment for compose yml",
|
||||
def __init__(self, cls, data, member = None):
|
||||
self.cls = cls
|
||||
self.data = data
|
||||
|
||||
'markers:project': "marker string for project directories",
|
||||
'markers:disabled': "marker string for disabled projects",
|
||||
if member is not None:
|
||||
self.member = member
|
||||
super().__init__(f"Invalid {self.cls.__name__!r}.{self.member!r} Format: {self.data!r}")
|
||||
|
||||
'network:name': "name for local network hub",
|
||||
'network:cidr': "CIDR block for local network hub",
|
||||
}
|
||||
else:
|
||||
super().__init__(f"Invalid {self.cls.__name__!r} Format: {self.data!r}")
|
||||
|
||||
def __key_resolve(self, key):
|
||||
"""
|
||||
Resolve nested dictionaries
|
||||
|
||||
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'
|
||||
"""
|
||||
class StorageConfig(BaseModel):
|
||||
"""a storage subsection"""
|
||||
|
||||
# "a:b:c" => path = ['a', 'b'], key = 'c'
|
||||
path = key.split(':')
|
||||
path, key = path[:-1], path[-1]
|
||||
directory: Path
|
||||
|
||||
# resolve path
|
||||
container = self.__yml_content
|
||||
for step in path:
|
||||
container = container[step]
|
||||
@property
|
||||
def kiwi_dict(self) -> Dict[str, Any]:
|
||||
"""write this object as a dictionary of strings"""
|
||||
|
||||
return container, key
|
||||
return {"directory": str(self.directory)}
|
||||
|
||||
def __getitem__(self, key):
|
||||
"""array-like read access to __yml_content"""
|
||||
@root_validator(pre=True)
|
||||
@classmethod
|
||||
def unify_storage(cls, values) -> Dict[str, Any]:
|
||||
"""parse different storage notations"""
|
||||
|
||||
container, key = self.__key_resolve(key)
|
||||
return container[key]
|
||||
if "directory" in values:
|
||||
# default format
|
||||
return values
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
"""array-like write access to __yml_content"""
|
||||
else:
|
||||
# undefined format
|
||||
raise InvalidFormatError(cls, str(values))
|
||||
|
||||
container, key = self.__key_resolve(key)
|
||||
container[key] = value
|
||||
|
||||
def __str__(self):
|
||||
"""dump into textual representation"""
|
||||
class ProjectNameReservedError(ValueError):
|
||||
"""raised if trying to create a project with a reserved name"""
|
||||
|
||||
# dump yml content
|
||||
yml_string = yaml.dump(
|
||||
self.__yml_content,
|
||||
default_flow_style=False, sort_keys=False
|
||||
).strip()
|
||||
name: str
|
||||
|
||||
# insert newline before every main key
|
||||
yml_string = re.sub(r'^(\S)', r'\n\1', yml_string, flags=re.MULTILINE)
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
super().__init__(f"Project name {self.name!r} is reserved!")
|
||||
|
||||
# load header comment from file
|
||||
with open(HEADER_KIWI_CONF_NAME, 'r') as stream:
|
||||
yml_string = stream.read() + yml_string
|
||||
|
||||
return yml_string
|
||||
class ProjectConfig(BaseModel):
|
||||
"""a project subsection"""
|
||||
|
||||
def _update_from_file(self, filename):
|
||||
"""return a copy updated using a kiwi.yml file"""
|
||||
name: constr(regex=RE_VARNAME)
|
||||
enabled: bool = True
|
||||
override_storage: Optional[StorageConfig]
|
||||
|
||||
with open(filename, 'r') as stream:
|
||||
try:
|
||||
# create copy
|
||||
result = Config()
|
||||
result.__yml_content = copy.deepcopy(self.__yml_content)
|
||||
@property
|
||||
def kiwi_dict(self) -> Dict[str, Any]:
|
||||
"""write this object as a dictionary of strings"""
|
||||
|
||||
# read file
|
||||
logging.debug(f"Reading '{filename}' into '{id(result.__yml_content)}'")
|
||||
result.__yml_content.update(yaml.safe_load(stream))
|
||||
result = self.dict(exclude={"override_storage"})
|
||||
|
||||
if self.override_storage is not None:
|
||||
result["override_storage"] = self.override_storage.kiwi_dict
|
||||
|
||||
return result
|
||||
except yaml.YAMLError as exc:
|
||||
logging.error(exc)
|
||||
|
||||
def user_query(self, key):
|
||||
"""query user for new config value"""
|
||||
@validator("name")
|
||||
@classmethod
|
||||
def check_project(cls, value: str) -> str:
|
||||
"""check if project name is allowed"""
|
||||
|
||||
# prompt user as per argument
|
||||
try:
|
||||
result = input(f"Enter {self.__keys[key]} [{self[key]}] ").strip()
|
||||
except EOFError:
|
||||
print()
|
||||
result = None
|
||||
if value in RESERVED_PROJECT_NAMES:
|
||||
raise ProjectNameReservedError(value)
|
||||
|
||||
# store result if present
|
||||
if result:
|
||||
self[key] = result
|
||||
return value
|
||||
|
||||
def save(self):
|
||||
"""save current yml representation in current directory's kiwi.yml"""
|
||||
@validator("override_storage", pre=True)
|
||||
@classmethod
|
||||
def unify_storage(cls, value) -> Dict[str, Any]:
|
||||
"""parse different storage notations"""
|
||||
|
||||
with open(KIWI_CONF_NAME, 'w') as stream:
|
||||
stream.write(str(self))
|
||||
stream.write('\n')
|
||||
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 DefaultConfig(Config):
|
||||
"""Singleton: The default kiwi.yml file"""
|
||||
class NetworkConfig(BaseModel):
|
||||
"""a network subsection"""
|
||||
|
||||
__instance = None
|
||||
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"""
|
||||
|
||||
version: constr(regex=RE_SEMVER) = "0.2.0"
|
||||
|
||||
shells: List[Path] = [
|
||||
Path("/bin/bash"),
|
||||
]
|
||||
|
||||
projects: List[ProjectConfig] = []
|
||||
|
||||
environment: Dict[str, Optional[str]] = {}
|
||||
|
||||
storage: StorageConfig = StorageConfig(
|
||||
directory="/var/local/kiwi",
|
||||
)
|
||||
|
||||
network: NetworkConfig = NetworkConfig(
|
||||
name="kiwi_hub",
|
||||
cidr="10.22.46.0/24",
|
||||
)
|
||||
|
||||
@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=5)
|
||||
def from_directory(cls, directory: Path) -> "KiwiConfig":
|
||||
"""parses an actual kiwi.yml from disk (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 singleton
|
||||
return cls.__instance
|
||||
|
||||
|
||||
class LoadedConfig(Config):
|
||||
"""Singleton collection: kiwi.yml files by path"""
|
||||
|
||||
__instances = {}
|
||||
|
||||
@classmethod
|
||||
def get(cls, directory='.'):
|
||||
if directory not in LoadedConfig.__instances:
|
||||
# create singleton for new path
|
||||
result = DefaultConfig.get()
|
||||
|
||||
# update with that dir's kiwi.yml
|
||||
try:
|
||||
result = result._update_from_file(os.path.join(directory, KIWI_CONF_NAME))
|
||||
with open(directory.joinpath(KIWI_CONF_NAME)) as kc:
|
||||
return cls.parse_obj(YAML().load(kc))
|
||||
|
||||
except FileNotFoundError:
|
||||
logging.info(f"No '{KIWI_CONF_NAME}' found at '{directory}'. Using defaults.")
|
||||
# return the defaults if no kiwi.yml found
|
||||
return cls.from_default()
|
||||
|
||||
LoadedConfig.__instances[directory] = result
|
||||
@classmethod
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def from_default(cls) -> "KiwiConfig":
|
||||
"""returns the default config (cached)"""
|
||||
|
||||
# return singleton
|
||||
return LoadedConfig.__instances[directory]
|
||||
return cls()
|
||||
|
||||
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
|
||||
|
||||
@property
|
||||
def kiwi_dict(self) -> Dict[str, Any]:
|
||||
"""write this object as a dictionary of strings"""
|
||||
|
||||
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 unify_shells(cls, value) -> List[str]:
|
||||
"""parse different shells notations"""
|
||||
|
||||
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:
|
||||
return [str(value)]
|
||||
|
||||
except Exception:
|
||||
# undefined format
|
||||
raise InvalidFormatError(KiwiConfig, value, "shells")
|
||||
|
||||
@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")
|
||||
|
|
|
@ -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
|
|
@ -10,6 +10,10 @@ networks:
|
|||
name: ${KIWI_HUB_NAME}
|
||||
|
||||
services:
|
||||
######################
|
||||
# START EDITING HERE #
|
||||
######################
|
||||
|
||||
# an example service
|
||||
something:
|
||||
# uses an image
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -1 +0,0 @@
|
|||
0.1.7
|
|
@ -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
|
||||
@attr.s
|
||||
class Executable:
|
||||
exe_name: str = attr.ib()
|
||||
|
||||
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):
|
||||
@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
|
||||
|
||||
raise FileNotFoundError(f"Executable '{exe_name}' not found in $PATH!")
|
||||
|
||||
@property
|
||||
def exe_file(self) -> Optional[Path]:
|
||||
return self.__find_exe_file(self.exe_name)
|
||||
|
||||
class Executable:
|
||||
class __Executable:
|
||||
__exe_path = None
|
||||
def __build_cmd(self, args, kwargs) -> List:
|
||||
cmd = [self.exe_file, *args]
|
||||
|
||||
def __init__(self, exe_name):
|
||||
self.__exe_path = _find_exe_file(exe_name)
|
||||
|
||||
def __build_cmd(self, args, kwargs):
|
||||
cmd = [self.__exe_path, *args]
|
||||
|
||||
logging.debug(f"Executable cmd{cmd}, kwargs{kwargs}")
|
||||
_logger.debug(f"Executable cmd{cmd}, kwargs{kwargs}")
|
||||
return cmd
|
||||
|
||||
def run(self, process_args, **kwargs):
|
||||
def run(self, process_args, **kwargs) -> Optional[subprocess.CompletedProcess]:
|
||||
return subprocess.run(
|
||||
self.__build_cmd(process_args, kwargs),
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def Popen(self, process_args, **kwargs):
|
||||
def Popen(self, process_args, **kwargs) -> subprocess.Popen:
|
||||
return subprocess.Popen(
|
||||
self.__build_cmd(process_args, kwargs),
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def run_less(self, process_args, **kwargs):
|
||||
kwargs['stdout'] = subprocess.PIPE
|
||||
kwargs['stderr'] = subprocess.DEVNULL
|
||||
def run_with_pager(self, process_args, **kwargs) -> Optional[subprocess.CompletedProcess]:
|
||||
kwargs["stdout"] = subprocess.PIPE
|
||||
kwargs["stderr"] = subprocess.DEVNULL
|
||||
|
||||
process = self.Popen(
|
||||
process_args,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
less_process = Executable('less').run([
|
||||
'-R', '+G'
|
||||
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 = {}
|
||||
|
||||
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
108
kiwi_scp/instance.py
Normal 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
|
||||
}
|
|
@ -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'
|
|
@ -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)
|
|
@ -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()
|
||||
])
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
from .executable import DOCKER_EXE
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
ROOTKIT_PREFIX = Path("/mnt")
|
||||
|
||||
|
||||
def _prefix_path(prefix, path):
|
||||
if isinstance(path, str):
|
||||
abs_path = os.path.abspath(path)
|
||||
return os.path.realpath(f"{prefix}/{abs_path}")
|
||||
@attr.s
|
||||
class Rootkit:
|
||||
image_tag: str = attr.ib()
|
||||
|
||||
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):
|
||||
@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
|
||||
|
||||
|
||||
class Rootkit:
|
||||
class __Rootkit:
|
||||
__image_tag = None
|
||||
|
||||
def __init__(self, image_tag=None):
|
||||
self.__image_tag = image_tag
|
||||
|
||||
def __exists(self):
|
||||
ps = Executable('docker').run([
|
||||
'images',
|
||||
'--filter', f"reference={_image_name(self.__image_tag)}",
|
||||
'--format', '{{.Repository}}:{{.Tag}}'
|
||||
@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):
|
||||
if self.__exists():
|
||||
logging.info(f"Using image {_image_name(self.__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:
|
||||
logging.info(f"Pulling image {_image_name(self.__image_tag)}")
|
||||
Executable('docker').run([
|
||||
'pull', _image_name(self.__image_tag)
|
||||
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)
|
||||
|
||||
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",
|
||||
_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)
|
||||
|
||||
def run(self, process_args, **kwargs):
|
||||
def run(self, process_args, **kwargs) -> Optional[subprocess.CompletedProcess]:
|
||||
any_sequence = TypeVar("any_sequence", Union[str, Path, Any], Sequence[Union[str, Path, Any]])
|
||||
|
||||
def parse_args(argument: any_sequence) -> any_sequence:
|
||||
if isinstance(argument, str):
|
||||
return argument
|
||||
|
||||
elif isinstance(argument, Path):
|
||||
if argument.is_absolute():
|
||||
argument = argument.relative_to("/")
|
||||
|
||||
return str(ROOTKIT_PREFIX.joinpath(argument))
|
||||
|
||||
elif not isinstance(argument, Sequence):
|
||||
return str(argument)
|
||||
|
||||
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()
|
||||
Executable('docker').run([
|
||||
'run', '--rm',
|
||||
'-v', '/:/mnt',
|
||||
'-u', 'root',
|
||||
_image_name(self.__image_tag),
|
||||
*process_args
|
||||
return DOCKER_EXE.run([
|
||||
"run", "--rm",
|
||||
"-v", f"/:{ROOTKIT_PREFIX!s}",
|
||||
"-u", "root",
|
||||
Rootkit.__image_name(self.image_tag),
|
||||
*parse_args(process_args)
|
||||
], **kwargs)
|
||||
|
||||
__image_tag = None
|
||||
__instances = {}
|
||||
|
||||
def __init__(self, image_tag=None):
|
||||
self.__image_tag = image_tag
|
||||
|
||||
if _image_name(self.__image_tag) not in Rootkit.__instances:
|
||||
Rootkit.__instances[_image_name(self.__image_tag)] = Rootkit.__Rootkit(image_tag)
|
||||
|
||||
def __getattr__(self, item):
|
||||
return getattr(self.__instances[_image_name(self.__image_tag)], item)
|
||||
|
|
|
@ -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
41
kiwi_scp/scripts/kiwi.py
Executable file → Normal 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
61
kiwi_scp/service.py
Normal 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
91
kiwi_scp/services.py
Normal 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
|
||||
])
|
|
@ -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)
|
|
@ -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',
|
||||
]
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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()
|
|
@ -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
|
|
@ -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()
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
97
kiwi_scp/wstring.py
Normal 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
37
kiwi_scp/yaml.py
Normal 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
571
poetry.lock
generated
|
@ -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"},
|
||||
]
|
||||
|
|
|
@ -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
0
tests/__init__.py
Normal file
491
tests/test_config.py
Normal file
491
tests/test_config.py
Normal 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
29
tests/test_instance.py
Normal 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
40
tests/test_project.py
Normal 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
66
tests/test_service.py
Normal 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
16
tests/test_services.py
Normal 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: []"
|
Loading…
Reference in a new issue