1
0
Fork 0
mirror of https://github.com/yavook/kiwi-scp.git synced 2024-11-21 12:23:00 +00:00

Merge branch 'release/0.0.1'

This commit is contained in:
Jörn-Michael Miehe 2020-08-24 17:01:55 +02:00
commit 1e010d0761
57 changed files with 2273 additions and 180 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
__pycache__/

4
.idea/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
# Default ignored files
/shelf/
/workspace.xml

View file

@ -0,0 +1,7 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="PROJECT_PROFILE" value="Default" />
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

11
.idea/kiwi-config.iml Normal file
View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/src/venv" />
</content>
<orderEntry type="jdk" jdkName="Pipenv (kiwi-config)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

4
.idea/misc.xml Normal file
View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Pipenv (kiwi-config)" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/kiwi-config.iml" filepath="$PROJECT_DIR$/.idea/kiwi-config.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Jörn-Michael Miehe
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

175
Makefile
View file

@ -1,175 +0,0 @@
#########
# CONFIGS
CONF_WILDC:=$(wildcard $(PWD)/*.conf)
CONF_SOURCE:=$(patsubst %,. %;,$(CONF_WILDC))
# extraction of env variables from *.conf files
confvalue=$(shell $(CONF_SOURCE) echo -n $${$(1)})
# docker network name
CONF_DOCKERNET:=$(call confvalue,DOCKERNET)
ifeq ($(CONF_DOCKERNET),)
$(error DOCKERNET not set in $(CONF_WILDC))
endif
# persistent data directory
CONF_TARGETROOT:=$(call confvalue,TARGETROOT)
ifeq ($(CONF_TARGETROOT),)
$(error TARGETROOT not set in $(CONF_WILDC))
endif
# suffix for project directories
PROJ_SUFFX:=$(call confvalue,SUFFIX_PROJECT)
ifeq ($(PROJ_SUFFX),)
$(error SUFFIX_PROJECT not set in $(CONF_WILDC))
endif
# suffix for disabled project directories
DOWN_SUFFX:=$(call confvalue,SUFFIX_DOWN)
ifeq ($(DOWN_SUFFX),)
$(error SUFFIX_DOWN not set in $(CONF_WILDC))
endif
#########
# CONSTANTS
# file to store docker network cidr
FILE_DOCKERNET:=$(CONF_TARGETROOT)/up-$(CONF_DOCKERNET)
# project directory handling
PROJ_WILDC:=$(wildcard *$(PROJ_SUFFX))
PROJ_NAMES:=$(basename $(PROJ_WILDC))
#########
# FUNCTIONS
# different complexities of commands with root privileges
# - in project directory
projsudo=cd "$<"; sudo bash -c "$(1)"
# - additionally with sourced *.conf files
confprojsudo=$(call projsudo,$(CONF_SOURCE) $(1))
# - only for compose: additionally with COMPOSE_PROJECT_NAME, CONFDIR and TARGETDIR set
sudocompose=$(call confprojsudo,COMPOSE_PROJECT_NAME="$(basename $<)" CONFDIR="$(CONF_TARGETROOT)/conf" TARGETDIR="$(CONF_TARGETROOT)/$<" docker-compose $(1))
#########
# TARGETS
# default target
.PHONY: all
all: purge-conf up
#########
# manage the docker network (container name local DNS)
$(FILE_DOCKERNET):
sudo docker network create "$(CONF_DOCKERNET)" ||:
sudo mkdir -p "$(CONF_TARGETROOT)"
sudo chmod 700 "$(CONF_TARGETROOT)"
sudo docker network inspect -f '{{(index .IPAM.Config 0).Subnet}}' "$(CONF_DOCKERNET)" | sudo tee "$@"
.PHONY: net-up
net-up: $(FILE_DOCKERNET)
.PHONY: net-down
net-down: down
sudo docker network rm $(CONF_DOCKERNET)
sudo rm $(FILE_DOCKERNET)
#########
# sync project config directory to variable folder
.PHONY: %-copyconf
%-copyconf: %$(PROJ_SUFFX)
@if [ -d "$</conf" ]; then \
sudo rsync -r "$</conf" "$(CONF_TARGETROOT)"; \
echo "Synced '$</conf' to '$(CONF_TARGETROOT)'"; \
fi
.PHONY: purge-conf
purge-conf:
sudo rm -rf "$(CONF_TARGETROOT)/conf"
#########
# manage all projects
.PHONY: up down update
up: net-up $(patsubst %,%-copyconf,$(PROJ_NAMES)) $(patsubst %,%-up,$(PROJ_NAMES))
down: $(patsubst %,%-down,$(PROJ_NAMES))
update: $(patsubst %,%-update,$(PROJ_NAMES))
#########
# manage single project
.PHONY: %-up
%-up: %$(PROJ_SUFFX)
$(call sudocompose,up -d $(x))
.PHONY: %-down
ifeq ($(x),)
%-down: %$(PROJ_SUFFX)
$(call sudocompose,down)
else
%-down: %$(PROJ_SUFFX)
$(call sudocompose,stop $(x))
$(call sudocompose,rm -f $(x))
endif
.PHONY: %-pull
%-pull: %$(PROJ_SUFFX)
$(call sudocompose,pull $(x))
.PHONY: %-build
%-build: %$(PROJ_SUFFX)
$(call sudocompose,build --pull $(x))
.PHONY: %-logs
%-logs: %$(PROJ_SUFFX)
$(call sudocompose,logs -t $(x)) 2>/dev/null | less -R +G
.PHONY: %-logf
%-logf: %$(PROJ_SUFFX)
$(call sudocompose,logs -tf --tail=10 $(x)) ||:
s?=bash
.PHONY: %-sh
%-sh: %$(PROJ_SUFFX)
$(call sudocompose,exec $(x) $(s)) ||:
# enabling and disabling
.PHONY: %-enable %-disable
%-enable: %$(PROJ_SUFFX)$(DOWN_SUFFX)
mv "$<" "$(basename $<)"
%-disable: %$(PROJ_SUFFX)
mv "$<" "$<$(DOWN_SUFFX)"
# Combinations
.PHONY: %-update
%-update: %$(PROJ_SUFFX) %-build %-pull
$(MAKE) $(basename $<)-up
# Arbitrary compose command
.PHONY: %-cmd
%-cmd: %$(PROJ_SUFFX)
$(call sudocompose,$(x))
#########
# project creation
.PHONY: %-new
%-new:
$(eval proj_dir:=$(patsubst %-new,%$(PROJ_SUFFX)$(DOWN_SUFFX),$@))
mkdir $(proj_dir)
$(eval export COMPOSEFILE)
echo -e "$$COMPOSEFILE" > $(proj_dir)/docker-compose.yml
# default compose file
define COMPOSEFILE
version: "3"
networks:
default:
external:
name: $$DOCKERNET
services:
something:
image: maintainer/repo:tag
restart: unless-stopped
[...]
endef

12
Pipfile Normal file
View file

@ -0,0 +1,12 @@
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true
[dev-packages]
[packages]
pyyaml = "*"
[requires]
python_version = "3.6"

38
Pipfile.lock generated Normal file
View file

@ -0,0 +1,38 @@
{
"_meta": {
"hash": {
"sha256": "d518a36ed441568acff15b0a3c4b536738a55fb68801cdd682045be04d29954a"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.6"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"pyyaml": {
"hashes": [
"sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
"sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76",
"sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
"sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648",
"sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf",
"sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f",
"sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2",
"sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee",
"sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d",
"sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
"sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"
],
"index": "pypi",
"version": "==5.3.1"
}
},
"develop": {}
}

23
README.md Normal file
View file

@ -0,0 +1,23 @@
# kiwi-config
The simple tool for managing container servers
## Quick start
- Learn to use `docker` with `docker-compose`
- Install kiwi-config
- Look at [the example instance](../example)
- Look at the output of `kiwi --help`
- Start building your own instances
## Installation
```shell script
curl 'https://raw.githubusercontent.com/ldericher/kiwi-config/master/install.sh' | sh
```
That script checks for the basic dependencies of the `kiwi` command, then downloads the main script and installs it to a location of your choice. Please consider installing `kiwi` into a directory inside your $PATH.
## Get started
TODO

View file

@ -1,5 +0,0 @@
export SUFFIX_PROJECT=.project
export SUFFIX_DOWN=.down
export DOCKERNET=kiwinet
export TARGETROOT=/var/kiwi

View file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Hello kiwi #2!</title>
<style>
body {
width: 50em;
margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif;
}
</style>
</head>
<body>
<h1>Another Hello World!</h1>
<p>This service uses an off-the-shelf image and the conf-directory feature of kiwi-config.</p>
<p><em>Greetings, nginx.</em></p>
</body>
</html>

View file

@ -0,0 +1,55 @@
version: "2"
networks:
# reachable from outside
default:
driver: bridge
# interconnects projects
kiwi_hub:
external:
name: ${KIWI_HUB_NAME}
services:
# simple loop producing (rather boring) logs
greeter:
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:
build: web
restart: unless-stopped
ports:
- "8080:80"
# internal mariadb (mysql) instance with persistent storage
db:
image: mariadb:10
restart: unless-stopped
networks:
- kiwi_hub
environment:
MYSQL_ROOT_PASSWORD: changeme
volumes:
- "${TARGETDIR}/db:/var/lib/mysql"
# admin interface for databases
adminer:
image: adminer:standalone
restart: unless-stopped
networks:
- default
- kiwi_hub
depends_on:
- db
ports:
- "8081:8080"
# Another webserver just to show off the ${CONFDIR} variable
another-web:
image: nginx:stable-alpine
restart: unless-stopped
ports:
- "8082:80"
volumes:
- "${CONFDIR}/html/index.html:/usr/share/nginx/html/index.html:ro"

View file

@ -0,0 +1,2 @@
FROM nginx:stable-alpine
COPY index.html /usr/share/nginx/html

View file

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Hello kiwi!</title>
<style>
body {
width: 50em;
margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif;
}
</style>
</head>
<body>
<h1>Hello World!</h1>
<h2>kiwi-config works.</h2>
<p>This is an example service on a custom image assembled by kiwi-config.<br /> But wait, there's more!</p>
<a href="//localhost:8081"><h3>Adminer</h3></a>
<p>There's a mySQL database included in this project. Login with user <q>root</q> and password <q>changeme</q>.</p>
<a href="//localhost:8082"><h3>Another hello world</h3></a>
<p>Another web server, built in a different way.</p>
<p><em>Greetings, nginx.</em></p>
</body>
</html>

19
example/kiwi.yml Normal file
View file

@ -0,0 +1,19 @@
######################################
# kiwi-config instance configuration #
######################################
version: '0.0.1'
runtime:
storage: /tmp/kiwi
shells:
- /bin/bash
env: null
markers:
project: .project
disabled: .disabled
network:
name: kiwi_hub
cidr: 10.22.46.0/24

85
install.sh Executable file
View file

@ -0,0 +1,85 @@
#!/bin/sh
#############
# CONSTANTS #
#############
# dependencies to run kiwi-config
KIWI_DEPS="bash python3 pipenv less"
##########
# CHECKS #
##########
printf "checking dependencies ... "
for dep in ${KIWI_DEPS}; do
printf "%s, " "${dep}"
if ! command -v "${dep}" >/dev/null 2>/dev/null; then
echo
echo "Dependency '${dep}' not found, please install!" >/dev/stderr
exit 1
fi
done
echo "OK"
########
# MAIN #
########
# prompt for installation directory
install_dir_default="/usr/local/bin"
valid="no"
while [ "${valid}" = "no" ]; do
printf "Select installation directory [Default: '%s']: " "${install_dir_default}"
read install_dir
install_dir="${install_dir:-${install_dir_default}}"
# check
if [ -d "${install_dir}" ]; then
valid="yes"
else
printf "Install directory doesn't exist. Try creating? [Y|n] "
read yesno
if [ ! "${yesno}" = "N" ] || [ ! "${yesno}" = "n" ]; then
# check creation failure
if mkdir -p "${install_dir}"; then
valid="yes"
else
echo "Invalid install directory." >/dev/stderr
exit 1
fi
fi
fi
done
# start actual installation
printf "Installing into '%s' ... " "${install_dir}"
# install "kiwi" script
uri="https://raw.githubusercontent.com/ldericher/kiwi-config/master/kiwi"
tmp_file="$(mktemp)"
if ! curl -o "${tmp_file}" "${uri}" >/dev/null 2>/dev/null; then
rm "${tmp_file}"
echo "Download 'kiwi' failed!" >/dev/stderr
exit 1
fi
if ! install -m 0755 "${tmp_file}" "${install_dir}/kiwi"; then
rm "${tmp_file}"
echo "Install 'kiwi' failed!" >/dev/stderr
exit 1
fi
rm "${tmp_file}"
# finalization
echo "OK"
exit 0

125
kiwi Executable file
View file

@ -0,0 +1,125 @@
#!/bin/bash
#############
# CONSTANTS #
#############
# base config filename
KIWI_CONF_NAME="kiwi.yml"
# version tag filename
KIWI_VERSION_TAG="etc/version_tag"
# dependencies to run kiwi-config
KIWI_DEPS=(docker docker-compose)
# base install dir
KIWI_BASEDIR="${HOME}/.local/lib/kiwi-config"
# per-user env setup script
KIWI_ENVFILE="${HOME}/.kiwienv"
# repository uri
KIWI_REPO="https://github.com/ldericher/kiwi-config"
# use latest version by default
KIWI_VERSION="master"
###################
# DYNAMIC STRINGS #
###################
# directory of correct installation
function kiwi_installdir() {
echo "${KIWI_BASEDIR}/${KIWI_VERSION}"
}
# src directory in installed version
function kiwi_root() {
echo "$(kiwi_installdir)/src"
}
# main script in installed version
function kiwi_executable() {
echo "$(kiwi_root)/kiwi-config.py"
}
# cache current work directory
WORKDIR="$(pwd)"
##################
# PER-USER SETUP #
##################
# add in environment setup
if [ -f "${KIWI_ENVFILE}" ]; then
# shellcheck source=$HOME/.kiwienv
source "${KIWI_ENVFILE}"
fi
##########
# CHECKS #
##########
for dep in "${KIWI_DEPS[@]}"; do
if ! command -v "${dep}" &>/dev/null; then
echo "Dependency '${dep}' not found, please install!" >/dev/stderr
exit 1
fi
done
########
# MAIN #
########
# check if pwd is a kiwi folder
if [ -f "./${KIWI_CONF_NAME}" ]; then
# determine needed kiwi-config version
re_version_line='version\s*:\s*'
eval "$(grep -E "${re_version_line}" "./${KIWI_CONF_NAME}" | sed -r "s/${re_version_line}/KIWI_VERSION=/")"
fi
# install if kiwi-config not found
if [ ! -x "$(kiwi_executable)" ]; then
echo -n "Installing kiwi-config v${KIWI_VERSION} into ${KIWI_BASEDIR} ... "
# switch to temp dir
tmpdir=$(mktemp -d)
cd "${tmpdir}" || :
# download archive
curl -o "kiwi-config.zip" "${KIWI_REPO}/archive/${KIWI_VERSION}.zip"
unzip "kiwi-config.zip"
# read archive version tag
cd "kiwi-config-${KIWI_VERSION}" || :
KIWI_VERSION=$(cat "./src/${KIWI_VERSION_TAG}")
# install archive
mkdir -p "$(kiwi_installdir)"
mv "src" "Pipfile" "Pipfile.lock" "$(kiwi_installdir)/"
# discard temp dir
cd "${WORKDIR}" || :
rm -rf "${tmpdir}"
echo "OK"
fi
# check virtualenv
cd "$(kiwi_installdir)" || :
if ! pipenv --venv &>/dev/null; then
# install virtualenv
echo -n "Preparing virtualenv ... "
pipenv sync &>/dev/null
echo "OK"
fi
# go back to the original work directory
cd "${WORKDIR}" || :
# setup main environment
KIWI_ROOT="$(kiwi_root)"
export KIWI_ROOT
export KIWI_CONF_NAME
export PIPENV_VERBOSITY=-1
# run main script
exec pipenv run "$(kiwi_executable)" "${@}"

23
src/etc/command_help.txt Normal file
View file

@ -0,0 +1,23 @@
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
clean Cleanly sync all configs to target folder, then relaunch affected projects
purge Remove all running docker artifacts of this instance
Commands for Instance Management:
config Create or configure kiwi-config instance
inspect Inspect projects in this instance, services inside a project or service(s) inside a project
cmd Run raw docker-compose command in a project
Commands for Project and Service Management:
new Create new empty project(s) in this instance
enable Enable project(s) in this instance
disable Disable project(s) in this instance
logs Show logs of a project or service(s) inside a project
shell Spawn shell inside a service inside a project
Commands for Image Handling:
build Build images for the whole instance, a project or service(s) inside a project
pull Pull images for the whole instance, a project or service(s) inside a project
push Push images for the whole instance, a project or service(s) inside a project

View file

@ -0,0 +1,22 @@
version: "2"
networks:
# reachable from outside
default:
driver: bridge
# interconnects projects
kiwi_hub:
external:
name: ${KIWI_HUB_NAME}
services:
# an example service
something:
# uses an image
image: maintainer/repo:tag
# will get restarted
restart: unless-stopped
# is also connected to the instance hub
networks:
- default
- kiwi_hub

12
src/etc/kiwi_default.yml Normal file
View file

@ -0,0 +1,12 @@
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

3
src/etc/kiwi_header.yml Normal file
View file

@ -0,0 +1,3 @@
######################################
# kiwi-config instance configuration #
######################################

9
src/etc/kiwi_help.txt Normal file
View file

@ -0,0 +1,9 @@
kiwi-config is the tool for container server management.
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
src/etc/version_tag Normal file
View file

@ -0,0 +1 @@
0.0.1

View file

@ -0,0 +1,2 @@
FROM alpine:latest
RUN apk --no-cache add rsync

39
src/kiwi-config.py Executable file
View file

@ -0,0 +1,39 @@
#!/usr/bin/env python3
# system
import logging
# local
import kiwi
def set_verbosity(logger, handler, verbosity):
"""set logging default verbosity level and format"""
if verbosity >= 2:
log_level = logging.DEBUG
log_format = "[%(asctime)s] %(levelname)s @ %(filename)s:%(funcName)s:%(lineno)d: %(message)s"
elif verbosity >= 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.verbosity())
# run the app
if not kiwi.run():
quit(1)
if __name__ == "__main__":
main()

20
src/kiwi/__init__.py Normal file
View file

@ -0,0 +1,20 @@
# 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'
]

36
src/kiwi/_constants.py Normal file
View file

@ -0,0 +1,36 @@
# system
import os
#############
# ENVIRONMENT
# location of "src" directory to use
KIWI_ROOT = os.getenv('KIWI_ROOT', ".")
# default name of kiwi-config file
KIWI_CONF_NAME = os.getenv('KIWI_CONF_NAME', "kiwi.yml")
############
# FILE NAMES
# text files inside kiwi-config "src" directory
HEADER_KIWI_CONF_NAME = f"{KIWI_ROOT}/etc/kiwi_header.yml"
DEFAULT_KIWI_CONF_NAME = f"{KIWI_ROOT}/etc/kiwi_default.yml"
VERSION_TAG_NAME = f"{KIWI_ROOT}/etc/version_tag"
DEFAULT_DOCKER_COMPOSE_NAME = f"{KIWI_ROOT}/etc/docker-compose_default.yml"
KIWI_HELP_TEXT_NAME = f"{KIWI_ROOT}/etc/kiwi_help.txt"
COMMAND_HELP_TEXT_NAME = f"{KIWI_ROOT}/etc/command_help.txt"
# special config directory in projects
CONF_DIRECTORY_NAME = 'conf'
# location for auxiliary Dockerfiles
IMAGES_DIRECTORY_NAME = f"{KIWI_ROOT}/images"
####################
# DOCKER IMAGE NAMES
# name for auxiliary docker images
LOCAL_IMAGES_NAME = 'localhost/kiwi-config/auxiliary'
DEFAULT_IMAGE_NAME = 'alpine:latest'

157
src/kiwi/config.py Normal file
View file

@ -0,0 +1,157 @@
# system
import copy
import logging
import os
import re
import yaml
# local
from ._constants import KIWI_CONF_NAME, HEADER_KIWI_CONF_NAME, DEFAULT_KIWI_CONF_NAME, VERSION_TAG_NAME
class Config:
"""represents a kiwi.yml"""
__yml_content = {}
__keys = {
'version': "kiwi-config version to use in this instance",
'runtime:storage': "local directory for service data",
'runtime:shells': "shell preference for working in service containers",
'runtime:env': "common environment for compose yml",
'markers:project': "marker string for project directories",
'markers:disabled': "marker string for disabled projects",
'network:name': "name for local network hub",
'network:cidr': "CIDR block for local network hub",
}
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'
"""
# "a:b:c" => path = ['a', 'b'], key = 'c'
path = key.split(':')
path, key = path[:-1], path[-1]
# resolve path
container = self.__yml_content
for step in path:
container = container[step]
return container, key
def __getitem__(self, key):
"""array-like read access to __yml_content"""
container, key = self.__key_resolve(key)
return container[key]
def __setitem__(self, key, value):
"""array-like write access to __yml_content"""
container, key = self.__key_resolve(key)
container[key] = value
def __str__(self):
"""dump into textual representation"""
# dump yml content
yml_string = yaml.dump(
self.__yml_content,
default_flow_style=False, sort_keys=False
).strip()
# insert newline before every main key
yml_string = re.sub(r'^(\S)', r'\n\1', yml_string, flags=re.MULTILINE)
# load header comment from file
with open(HEADER_KIWI_CONF_NAME, 'r') as stream:
yml_string = stream.read() + yml_string
return yml_string
def _update_from_file(self, filename):
"""return a copy updated using a kiwi.yml file"""
with open(filename, 'r') as stream:
try:
# create copy
result = Config()
result.__yml_content = copy.deepcopy(self.__yml_content)
# read file
logging.debug(f"Reading '{filename}' into '{id(result.__yml_content)}'")
result.__yml_content.update(yaml.safe_load(stream))
return result
except yaml.YAMLError as exc:
logging.error(exc)
def user_query(self, key):
"""query user for new config value"""
# prompt user as per argument
try:
result = input(f"Enter {self.__keys[key]} [{self[key]}] ").strip()
except EOFError:
print()
result = None
# store result if present
if result:
self[key] = result
def save(self):
"""save current yml representation in current directory's kiwi.yml"""
with open(KIWI_CONF_NAME, 'w') as stream:
stream.write(str(self))
stream.write('\n')
class DefaultConfig(Config):
"""Singleton: The default kiwi.yml file"""
__instance = None
@classmethod
def get(cls):
if cls.__instance is None:
# create singleton
cls.__instance = cls()._update_from_file(DEFAULT_KIWI_CONF_NAME)
# 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))
except FileNotFoundError:
logging.info(f"No '{KIWI_CONF_NAME}' found at '{directory}'. Using defaults.")
LoadedConfig.__instances[directory] = result
# return singleton
return LoadedConfig.__instances[directory]

77
src/kiwi/executable.py Normal file
View file

@ -0,0 +1,77 @@
# system
import logging
import os
import subprocess
# local
from .config import LoadedConfig
def _is_executable(filename):
if filename is None:
return False
return os.path.isfile(filename) and os.access(filename, os.X_OK)
def _find_exe_file(exe_name):
for path in os.environ['PATH'].split(os.pathsep):
exe_file = os.path.join(path, exe_name)
if _is_executable(exe_file):
return exe_file
raise FileNotFoundError(f"Executable '{exe_name}' not found in $PATH!")
class Executable:
class __Executable:
__exe_path = None
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}")
return cmd
def run(self, process_args, **kwargs):
return subprocess.run(
self.__build_cmd(process_args, kwargs),
**kwargs
)
def Popen(self, process_args, **kwargs):
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
process = self.Popen(
process_args,
**kwargs
)
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)

37
src/kiwi/misc.py Normal file
View file

@ -0,0 +1,37 @@
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 PROGRESS', '!')}\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'

66
src/kiwi/parser.py Normal file
View file

@ -0,0 +1,66 @@
# 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)

133
src/kiwi/project.py Normal file
View file

@ -0,0 +1,133 @@
import logging
import os
from .executable import Executable
from ._constants import CONF_DIRECTORY_NAME
from .config import LoadedConfig
class Project:
__name = None
def __init__(self, name):
self.__name = name
@classmethod
def from_file_name(cls, file_name):
if os.path.isdir(file_name):
config = LoadedConfig.get()
if file_name.endswith(config['markers:disabled']):
file_name = file_name[:-len(config['markers:disabled'])]
if file_name.endswith(config['markers:project']):
file_name = file_name[:-len(config['markers:project'])]
return cls(file_name)
return None
def get_name(self):
return self.__name
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
def enabled_dir_name(self):
return f"{self.__name}{LoadedConfig.get()['markers:project']}"
def disabled_dir_name(self):
return f"{self.enabled_dir_name()}{LoadedConfig.get()['markers:disabled']}"
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'],
'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

83
src/kiwi/projects.py Normal file
View file

@ -0,0 +1,83 @@
import os
from kiwi.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

86
src/kiwi/rootkit.py Normal file
View file

@ -0,0 +1,86 @@
# system
import logging
import os
import subprocess
# local
from ._constants import IMAGES_DIRECTORY_NAME, LOCAL_IMAGES_NAME, DEFAULT_IMAGE_NAME
from .executable import Executable
def _prefix_path(prefix, path):
if isinstance(path, str):
abs_path = os.path.abspath(path)
return os.path.realpath(f"{prefix}/{abs_path}")
elif isinstance(path, list):
return [_prefix_path(prefix, p) for p in path]
def prefix_path_mnt(path):
return _prefix_path('/mnt/', path)
def _image_name(image_tag):
if image_tag is not None:
return f"{LOCAL_IMAGES_NAME}:{image_tag}"
else:
return DEFAULT_IMAGE_NAME
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}}'
], stdout=subprocess.PIPE)
return str(ps.stdout, 'utf-8').strip() == _image_name(self.__image_tag)
def __build_image(self):
if self.__exists():
logging.info(f"Using image {_image_name(self.__image_tag)}")
else:
if self.__image_tag is None:
logging.info(f"Pulling image {_image_name(self.__image_tag)}")
Executable('docker').run([
'pull', _image_name(self.__image_tag)
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
logging.info(f"Building image {_image_name(self.__image_tag)}")
Executable('docker').run([
'build',
'-t', _image_name(self.__image_tag),
'-f', f"{IMAGES_DIRECTORY_NAME}/{self.__image_tag}.Dockerfile",
f"{IMAGES_DIRECTORY_NAME}"
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def run(self, process_args, **kwargs):
self.__build_image()
Executable('docker').run([
'run', '--rm',
'-v', '/:/mnt',
'-u', 'root',
_image_name(self.__image_tag),
*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)

73
src/kiwi/runner.py Normal file
View file

@ -0,0 +1,73 @@
# 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)

128
src/kiwi/subcommand.py Normal file
View file

@ -0,0 +1,128 @@
# 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-config instance at '{os.getcwd()}'")
return self._run_instance(runner, args)
class ProjectCommand(SubCommand):
"""this command concerns a project in current instance"""
def __init__(self, name, num_projects, action, add_parser=True, **kwargs):
super().__init__(
name, action=action, add_parser=add_parser,
**kwargs
)
if num_projects == 1:
projects = "a project"
else:
projects = "project(s)"
self._sub_parser.add_argument(
'projects', metavar='project', nargs=num_projects, type=str,
help=f"select {projects} in this instance"
)
def _run_instance(self, runner, args):
# default: run for all enabled projects
return self._run_projects(runner, args, Projects.from_dir().filter_enabled())
def _run_projects(self, runner, args, projects):
# default: run for all given projects
return all([
self._run_project(runner, args, project)
for project in projects
])
def _run_project(self, runner, args, project):
pass
def run(self, runner, args):
projects = Projects.from_args(args)
if projects:
# project(s) given
logging.info(f"{self._action} projects {projects}")
return self._run_projects(runner, args, projects)
else:
return super().run(runner, args)
class ServiceCommand(ProjectCommand):
"""this command concerns service(s) in a project"""
def __init__(self, name, num_projects, num_services, action, add_parser=True, **kwargs):
super().__init__(
name, num_projects=num_projects, action=action, add_parser=add_parser,
**kwargs
)
if (isinstance(num_projects, str) and num_projects == '*') \
or (isinstance(num_projects, int) and num_projects > 1):
raise ValueError(f"Invalid choice for project count: {num_projects}")
if num_services == 1:
services = "a service"
else:
services = "service(s)"
self._sub_parser.add_argument(
'services', metavar='service', nargs=num_services, type=str,
help=f"select {services} in a project"
)
def _run_project(self, runner, args, project):
# default: run with empty service list
return self._run_services(runner, args, project, [])
def _run_services(self, runner, args, project, services):
pass
def run(self, runner, args):
if 'services' in args and args.services:
project = Projects.from_args(args)[0]
# run for service(s) inside project
logging.info(f"{self._action} project '{project.get_name()}', services {args.services}")
return self._run_services(runner, args, project, args.services)
else:
return super().run(runner, args)

View file

@ -0,0 +1,42 @@
# local
from ._hidden import ConfCopyCommand, ConfPurgeCommand, NetUpCommand
from .build import BuildCommand
from .clean import CleanCommand
from .cmd import CmdCommand
from .config import ConfigCommand
from .disable import DisableCommand
from .down import DownCommand
from .enable import EnableCommand
from .inspect import InspectCommand
from .logs import LogsCommand
from .new import NewCommand
from .pull import PullCommand
from .purge import PurgeCommand
from .push import PushCommand
from .shell import ShellCommand
from .up import UpCommand
from .update import UpdateCommand
__all__ = [
'ConfCopyCommand',
'ConfPurgeCommand',
'NetUpCommand',
'BuildCommand',
'CleanCommand',
'CmdCommand',
'ConfigCommand',
'DisableCommand',
'DownCommand',
'EnableCommand',
'InspectCommand',
'LogsCommand',
'NewCommand',
'PullCommand',
'PurgeCommand',
'PushCommand',
'ShellCommand',
'UpCommand',
'UpdateCommand',
]

View file

@ -0,0 +1,106 @@
# system
import logging
import subprocess
# local
from .._constants import CONF_DIRECTORY_NAME
from ..executable import Executable
from ..subcommand import SubCommand
from ..config import LoadedConfig
from ..projects import Projects
from ..rootkit import Rootkit, prefix_path_mnt
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 conf_dirs:
# add target directory
conf_dirs.append(LoadedConfig.get()['runtime:storage'])
logging.info(f"Sync directories: {conf_dirs}")
Rootkit('rsync').run([
'rsync', '-r', *prefix_path_mnt(conf_dirs)
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True
class ConfPurgeCommand(SubCommand):
"""kiwi conf-purge"""
def __init__(self):
super().__init__(
'conf-purge',
action="Removing all configs for", add_parser=False,
description="Remove all config files in target directory"
)
def _run_instance(self, runner, args):
conf_target = f"{LoadedConfig.get()['runtime:storage']}/{CONF_DIRECTORY_NAME}"
logging.info(f"Purging directories: {conf_target}")
Rootkit().run([
'rm', '-rf', prefix_path_mnt(conf_target)
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True
def _find_net(net_name):
ps = Executable('docker').run([
'network', 'ls', '--filter', f"name={net_name}", '--format', '{{.Name}}'
], stdout=subprocess.PIPE)
net_found = str(ps.stdout, 'utf-8').strip()
return net_found == net_name
class NetUpCommand(SubCommand):
"""kiwi net-up"""
def __init__(self):
super().__init__(
'net-up',
action="Creating the local network hub for", add_parser=False,
description="Create the local network hub for this instance"
)
def _run_instance(self, runner, args):
config = LoadedConfig.get()
net_name = config['network:name']
net_cidr = config['network:cidr']
if _find_net(net_name):
logging.info(f"Network '{net_name}' already exists")
return True
try:
Executable('docker').run([
'network', 'create',
'--driver', 'bridge',
'--internal',
'--subnet', net_cidr,
net_name
], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
logging.info(f"Network '{net_name}' created")
except subprocess.CalledProcessError:
logging.error(f"Error creating network '{net_name}'")
return False
return True

View file

@ -0,0 +1,18 @@
# local
from ..subcommand import ServiceCommand
class BuildCommand(ServiceCommand):
"""kiwi build"""
def __init__(self):
super().__init__(
'build', num_projects='?', num_services='*',
action="Building images for",
description="Build images for the whole instance, a project or service(s) inside a project"
)
def _run_services(self, runner, args, project, services):
project.compose_run(['build', '--pull', *services])
return True

View file

@ -0,0 +1,37 @@
from ..projects import Projects
from ..subcommand import SubCommand
class CleanCommand(SubCommand):
"""kiwi clean"""
def __init__(self):
super().__init__(
'clean',
action="Cleaning all configs for",
description="Cleanly sync all configs to target folder, then relaunch affected projects"
)
def _run_instance(self, runner, args):
result = True
affected_projects = [
project.get_name()
for project in Projects.from_dir()
if project.has_configs()
]
for project_name in affected_projects:
args.projects = project_name
result &= runner.run('down')
# cleanly sync configs
result &= runner.run('conf-purge')
result &= runner.run('conf-copy')
# bring projects back up
for project_name in affected_projects:
args.projects = project_name
result &= runner.run('up')
return result

View file

@ -0,0 +1,40 @@
# system
import logging
# local
from ..subcommand import ProjectCommand
class CmdCommand(ProjectCommand):
"""kiwi cmd"""
def __init__(self):
super().__init__(
'cmd', num_projects=1,
action="Running docker-compose in",
description="Run raw docker-compose command in a project"
)
# command for docker-compose
self._sub_parser.add_argument(
'compose_cmd', metavar='cmd', type=str,
help="command for 'docker-compose'"
)
# arguments for docker-compose command
self._sub_parser.add_argument(
'compose_args', metavar='arg', nargs='*', type=str,
help="arguments for 'docker-compose' commands"
)
def _run_project(self, runner, args, project):
if args.unknowns:
args.compose_args = [*args.compose_args, *args.unknowns]
args.unknowns = []
logging.debug(f"Updated args: {args}")
# run with split compose_cmd argument
project.compose_run([args.compose_cmd, *args.compose_args])
return True

View file

@ -0,0 +1,64 @@
# system
import logging
import os
# local
from .._constants import KIWI_CONF_NAME
from ..subcommand import SubCommand
from ..config import DefaultConfig, LoadedConfig
class ConfigCommand(SubCommand):
"""kiwi config"""
def __init__(self):
super().__init__(
'config',
action=f"Configuring '{KIWI_CONF_NAME}' in",
description="Create or configure kiwi-config instance"
)
# -f switch: Initialize with default config
self._sub_parser.add_argument(
'-f', '--force',
action='store_true',
help=f"use default values even if {KIWI_CONF_NAME} is present"
)
# -s switch: Show current config instead
self._sub_parser.add_argument(
'-s', '--show',
action='store_true',
help=f"show effective {KIWI_CONF_NAME} contents instead"
)
def _run_instance(self, runner, args):
config = LoadedConfig.get()
# check show switch
if args.show:
print(config)
return True
# check force switch
if args.force and os.path.isfile(KIWI_CONF_NAME):
logging.warning(f"Overwriting existing '{KIWI_CONF_NAME}'!")
config = DefaultConfig.get()
# version
config.user_query('version')
# runtime
config.user_query('runtime:storage')
# markers
config.user_query('markers:project')
config.user_query('markers:disabled')
# network
config.user_query('network:name')
config.user_query('network:cidr')
config.save()
return True

View file

@ -0,0 +1,16 @@
# local
from ..subcommand import ProjectCommand
class DisableCommand(ProjectCommand):
"""kiwi disable"""
def __init__(self):
super().__init__(
'disable', num_projects='+',
action="Disabling",
description="Disable project(s) in this instance"
)
def _run_project(self, runner, args, project):
return project.disable()

View file

@ -0,0 +1,35 @@
# local
from ..subcommand import ServiceCommand
from ..misc import are_you_sure
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):
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",
]):
return super()._run_instance(runner, args)
return False
def _run_project(self, runner, args, project):
project.compose_run(['down'])
return True
def _run_services(self, runner, args, project, services):
project.compose_run(['stop', *services])
project.compose_run(['rm', '-f', *services])
return True

View file

@ -0,0 +1,16 @@
# local
from ..subcommand import ProjectCommand
class EnableCommand(ProjectCommand):
"""kiwi enable"""
def __init__(self):
super().__init__(
'enable', num_projects='+',
action="Enabling",
description="Enable project(s) in this instance"
)
def _run_project(self, runner, args, project):
return project.enable()

View file

@ -0,0 +1,98 @@
# system
import logging
import os
import yaml
# local
from ..subcommand import ServiceCommand
from ..project import Project
from ..projects import Projects
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 InspectCommand(ServiceCommand):
"""kiwi inspect"""
def __init__(self):
super().__init__(
'inspect', num_projects='?', num_services='*',
action="Inspecting",
description="Inspect projects in this instance, services inside a project or service(s) inside a project"
)
def _run_instance(self, runner, args):
print(f"kiwi-config instance at '{os.getcwd()}'")
print("#########")
projects = Projects.from_dir()
enabled_projects = projects.filter_enabled()
if enabled_projects:
print(f"Enabled projects:")
_print_list(enabled_projects)
disabled_projects = projects.filter_disabled()
if disabled_projects:
print(f"Disabled projects:")
_print_list(disabled_projects)
return True
def _run_project(self, runner, args, project):
if not project.exists():
logging.warning(f"Project '{project.get_name()}' not found")
return False
print(f"Services in project '{project.get_name()}':")
print("#########")
with open(project.compose_file_name(), 'r') as stream:
try:
docker_compose_yml = yaml.safe_load(stream)
_print_list(docker_compose_yml['services'].keys())
except yaml.YAMLError as exc:
logging.error(exc)
return True
def _run_services(self, runner, args, project, services):
if not project.exists():
logging.error(f"Project '{project.get_name()}' not found")
return False
print(f"Configuration of services {services} in project '{project.get_name()}':")
print("#########")
with open(project.compose_file_name(), 'r') as stream:
try:
docker_compose_yml = yaml.safe_load(stream)
for service_name in services:
try:
print(yaml.dump(
{service_name: docker_compose_yml['services'][service_name]},
default_flow_style=False, sort_keys=False
).strip())
except KeyError:
logging.error(f"Service '{service_name}' not found")
return True
except yaml.YAMLError as exc:
logging.error(exc)
return False

View file

@ -0,0 +1,40 @@
# local
from ..subcommand import ServiceCommand
class LogsCommand(ServiceCommand):
"""kiwi logs"""
def __init__(self):
super().__init__(
'logs', num_projects=1, num_services='*',
action="Showing logs of",
description="Show logs of a project or service(s) inside a project"
)
# -f switch: Follow logs
self._sub_parser.add_argument(
'-f', '--follow', action='store_true',
help="output appended data as log grows"
)
def _run_services(self, runner, args, project, services):
# include timestamps
compose_cmd = ['logs', '-t']
# handle following the log output
if args.follow:
compose_cmd = [*compose_cmd, '-f', '--tail=10']
# append if one or more services are given
if services:
compose_cmd = [*compose_cmd, *args.services]
if args.follow:
project.compose_run(compose_cmd)
else:
# use 'less' viewer if output is static
project.compose_run_less(compose_cmd)
return True

View file

@ -0,0 +1,30 @@
# system
import logging
import os
import shutil
# local
from .._constants import DEFAULT_DOCKER_COMPOSE_NAME
from ..subcommand import ProjectCommand
class NewCommand(ProjectCommand):
"""kiwi new"""
def __init__(self):
super().__init__(
'new', num_projects='+',
action="Creating",
description="Create new empty project(s) in this instance"
)
def _run_project(self, runner, args, project):
if project.exists():
logging.error(f"Project '{project.get_name()}' exists in this instance!")
return False
else:
os.mkdir(project.disabled_dir_name())
shutil.copy(DEFAULT_DOCKER_COMPOSE_NAME, project.compose_file_name())
logging.debug(f"Project '{project.get_name()}' created")
return True

View file

@ -0,0 +1,18 @@
# local
from ..subcommand import ServiceCommand
class PullCommand(ServiceCommand):
"""kiwi pull"""
def __init__(self):
super().__init__(
'pull', num_projects='?', num_services='*',
action="Pulling images for",
description="Pull images for the whole instance, a project or service(s) inside a project"
)
def _run_services(self, runner, args, project, services):
project.compose_run(['pull', '--ignore-pull-failures', *services])
return True

View file

@ -0,0 +1,45 @@
# system
import logging
import subprocess
# local
from ._hidden import _find_net
from ..subcommand import SubCommand
from ..config import LoadedConfig
from ..executable import Executable
from ..misc import are_you_sure
class PurgeCommand(SubCommand):
"""kiwi purge"""
def __init__(self):
super().__init__(
'purge',
action="Tearing down",
description="Remove all running docker artifacts of this instance"
)
def _run_instance(self, runner, args):
net_name = LoadedConfig.get()['network:name']
if not _find_net(net_name):
logging.info(f"Network '{net_name}' does not exist")
return True
try:
if are_you_sure("This will bring down this instance's hub network!"):
if runner.run('down'):
Executable('docker').run([
'network', 'rm', net_name
], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
logging.info(f"Network '{net_name}' removed")
else:
return False
except subprocess.CalledProcessError:
logging.error(f"Error removing network '{net_name}'")
return False
return True

View file

@ -0,0 +1,18 @@
# local
from ..subcommand import ServiceCommand
class PushCommand(ServiceCommand):
"""kiwi push"""
def __init__(self):
super().__init__(
'push', num_projects='?', num_services='*',
action="Pushing images for",
description="Push images for the whole instance, a project or service(s) inside a project"
)
def _run_services(self, runner, args, project, services):
project.compose_run(['push', *services])
return True

View file

@ -0,0 +1,104 @@
# system
import logging
import subprocess
# local
from ..subcommand import ServiceCommand
from ..config import LoadedConfig
def _service_has_executable(project, service, exe_name):
"""
Test if service in project has an executable exe_name in its PATH.
Requires /bin/sh.
"""
try:
# test if desired shell exists
project.compose_run(
['exec', service, '/bin/sh', '-c', f"command -v {exe_name}"],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
return True
except subprocess.CalledProcessError as e:
# fallback
return False
def _find_shell(args, project, service):
"""find first working shell (provided by config and args) in service in project"""
# builtin shells: as a last resort, fallback to '/bin/sh' and 'sh'
shells = ['/bin/sh', 'sh']
# load favorite shells from config
config = LoadedConfig.get()
if config['runtime:shells']:
shells = [*config['runtime:shells'], *shells]
# consider shell from args
if args.shell:
shells = [args.shell, *shells]
logging.debug(f"Shells priority: {shells}")
# actually try shells
for i, shell in enumerate(shells):
if _service_has_executable(project, service, shell):
# found working shell
logging.debug(f"Using shell '{shell}'")
return shell
elif i + 1 < len(shells):
# try next in list
logging.info(f"Shell '{shell}' not found in container, trying '{shells[i+1]}'")
elif args.shell:
# not found, user suggestion provided
logging.warning(f"Could not find any working shell in this container. "
f"Launching provided '{args.shell}' nevertheless. "
f"Don't get mad if this fails!")
return args.shell
else:
# not found, search exhausted
logging.error(f"Could not find any working shell among '{shells}' in this container. "
f"Please suggest a shell using the '-s SHELL' command line option!")
return None
class ShellCommand(ServiceCommand):
"""kiwi shell"""
def __init__(self):
super().__init__(
'shell', num_projects=1, num_services=1,
action="Spawning shell in",
description="Spawn shell inside a project's service"
)
# -s argument: Select shell
self._sub_parser.add_argument(
'-s', '--shell', type=str,
help="shell to spawn"
)
# -u argument: Run as user
self._sub_parser.add_argument(
'-u', '--user', type=str,
help="container user to run shell"
)
def _run_services(self, runner, args, project, services):
service = services[0]
shell = _find_shell(args, project, service)
user_args = ['-u', args.user] if args.user else []
if shell is not None:
# spawn shell
project.compose_run(['exec', *user_args, service, shell])
return True
return False

View file

@ -0,0 +1,26 @@
# local
from ..subcommand import ServiceCommand
class UpCommand(ServiceCommand):
"""kiwi up"""
def __init__(self):
super().__init__(
'up', num_projects='?', num_services='*',
action="Bringing up",
description="Bring up the whole instance, a project or service(s) inside a project"
)
def _run_instance(self, runner, args):
if runner.run('conf-copy'):
return super()._run_instance(runner, args)
return False
def _run_services(self, runner, args, project, services):
if runner.run('net-up'):
project.compose_run(['up', '-d', *services])
return True
return False

View file

@ -0,0 +1,37 @@
# local
from ..subcommand import ServiceCommand
from ..misc import are_you_sure
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