diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c18dd8d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+__pycache__/
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..351c96d
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,4 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
new file mode 100644
index 0000000..dd4c951
--- /dev/null
+++ b/.idea/inspectionProfiles/profiles_settings.xml
@@ -0,0 +1,7 @@
+
This service uses an off-the-shelf image and the conf-directory feature of kiwi-config.
+ +Greetings, nginx.
+ + + \ No newline at end of file diff --git a/example/hello-world.project/docker-compose.yml b/example/hello-world.project/docker-compose.yml new file mode 100644 index 0000000..9a7ea1e --- /dev/null +++ b/example/hello-world.project/docker-compose.yml @@ -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" diff --git a/example/hello-world.project/web/Dockerfile b/example/hello-world.project/web/Dockerfile new file mode 100644 index 0000000..d479ce9 --- /dev/null +++ b/example/hello-world.project/web/Dockerfile @@ -0,0 +1,2 @@ +FROM nginx:stable-alpine +COPY index.html /usr/share/nginx/html \ No newline at end of file diff --git a/example/hello-world.project/web/index.html b/example/hello-world.project/web/index.html new file mode 100644 index 0000000..407e6bd --- /dev/null +++ b/example/hello-world.project/web/index.html @@ -0,0 +1,31 @@ + + + + + +This is an example service on a custom image assembled by kiwi-config.
But wait, there's more!
There's a mySQL database included in this project. Login with user root
and password changeme
.
Another web server, built in a different way.
+ +Greetings, nginx.
+ + + \ No newline at end of file diff --git a/example/kiwi.yml b/example/kiwi.yml new file mode 100644 index 0000000..9965083 --- /dev/null +++ b/example/kiwi.yml @@ -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 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..3ee19c3 --- /dev/null +++ b/install.sh @@ -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 \ No newline at end of file diff --git a/kiwi b/kiwi new file mode 100755 index 0000000..e4cb044 --- /dev/null +++ b/kiwi @@ -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)" "${@}" diff --git a/src/etc/command_help.txt b/src/etc/command_help.txt new file mode 100644 index 0000000..51ae022 --- /dev/null +++ b/src/etc/command_help.txt @@ -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 \ No newline at end of file diff --git a/src/etc/docker-compose_default.yml b/src/etc/docker-compose_default.yml new file mode 100644 index 0000000..47f85e4 --- /dev/null +++ b/src/etc/docker-compose_default.yml @@ -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 diff --git a/src/etc/kiwi_default.yml b/src/etc/kiwi_default.yml new file mode 100644 index 0000000..1404379 --- /dev/null +++ b/src/etc/kiwi_default.yml @@ -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 diff --git a/src/etc/kiwi_header.yml b/src/etc/kiwi_header.yml new file mode 100644 index 0000000..95d3b8f --- /dev/null +++ b/src/etc/kiwi_header.yml @@ -0,0 +1,3 @@ +###################################### +# kiwi-config instance configuration # +###################################### diff --git a/src/etc/kiwi_help.txt b/src/etc/kiwi_help.txt new file mode 100644 index 0000000..60f6ba2 --- /dev/null +++ b/src/etc/kiwi_help.txt @@ -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 diff --git a/src/etc/version_tag b/src/etc/version_tag new file mode 100644 index 0000000..8a9ecc2 --- /dev/null +++ b/src/etc/version_tag @@ -0,0 +1 @@ +0.0.1 \ No newline at end of file diff --git a/src/images/rsync.Dockerfile b/src/images/rsync.Dockerfile new file mode 100644 index 0000000..47c262e --- /dev/null +++ b/src/images/rsync.Dockerfile @@ -0,0 +1,2 @@ +FROM alpine:latest +RUN apk --no-cache add rsync \ No newline at end of file diff --git a/src/kiwi-config.py b/src/kiwi-config.py new file mode 100755 index 0000000..7435727 --- /dev/null +++ b/src/kiwi-config.py @@ -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() diff --git a/src/kiwi/__init__.py b/src/kiwi/__init__.py new file mode 100644 index 0000000..ba8912f --- /dev/null +++ b/src/kiwi/__init__.py @@ -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' +] diff --git a/src/kiwi/_constants.py b/src/kiwi/_constants.py new file mode 100644 index 0000000..11726b0 --- /dev/null +++ b/src/kiwi/_constants.py @@ -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' diff --git a/src/kiwi/config.py b/src/kiwi/config.py new file mode 100644 index 0000000..d5de124 --- /dev/null +++ b/src/kiwi/config.py @@ -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] diff --git a/src/kiwi/executable.py b/src/kiwi/executable.py new file mode 100644 index 0000000..3d79983 --- /dev/null +++ b/src/kiwi/executable.py @@ -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) diff --git a/src/kiwi/misc.py b/src/kiwi/misc.py new file mode 100644 index 0000000..90753ab --- /dev/null +++ b/src/kiwi/misc.py @@ -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' diff --git a/src/kiwi/parser.py b/src/kiwi/parser.py new file mode 100644 index 0000000..5276152 --- /dev/null +++ b/src/kiwi/parser.py @@ -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) diff --git a/src/kiwi/project.py b/src/kiwi/project.py new file mode 100644 index 0000000..5072aa8 --- /dev/null +++ b/src/kiwi/project.py @@ -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 diff --git a/src/kiwi/projects.py b/src/kiwi/projects.py new file mode 100644 index 0000000..620fc81 --- /dev/null +++ b/src/kiwi/projects.py @@ -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 diff --git a/src/kiwi/rootkit.py b/src/kiwi/rootkit.py new file mode 100644 index 0000000..a3c7937 --- /dev/null +++ b/src/kiwi/rootkit.py @@ -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) diff --git a/src/kiwi/runner.py b/src/kiwi/runner.py new file mode 100644 index 0000000..a2b33dd --- /dev/null +++ b/src/kiwi/runner.py @@ -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) diff --git a/src/kiwi/subcommand.py b/src/kiwi/subcommand.py new file mode 100644 index 0000000..072ce3f --- /dev/null +++ b/src/kiwi/subcommand.py @@ -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) diff --git a/src/kiwi/subcommands/__init__.py b/src/kiwi/subcommands/__init__.py new file mode 100644 index 0000000..76ff7ab --- /dev/null +++ b/src/kiwi/subcommands/__init__.py @@ -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', +] diff --git a/src/kiwi/subcommands/_hidden.py b/src/kiwi/subcommands/_hidden.py new file mode 100644 index 0000000..bd7bbc8 --- /dev/null +++ b/src/kiwi/subcommands/_hidden.py @@ -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 diff --git a/src/kiwi/subcommands/build.py b/src/kiwi/subcommands/build.py new file mode 100644 index 0000000..35cb4e5 --- /dev/null +++ b/src/kiwi/subcommands/build.py @@ -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 diff --git a/src/kiwi/subcommands/clean.py b/src/kiwi/subcommands/clean.py new file mode 100644 index 0000000..22422e5 --- /dev/null +++ b/src/kiwi/subcommands/clean.py @@ -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 diff --git a/src/kiwi/subcommands/cmd.py b/src/kiwi/subcommands/cmd.py new file mode 100644 index 0000000..c890edf --- /dev/null +++ b/src/kiwi/subcommands/cmd.py @@ -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 diff --git a/src/kiwi/subcommands/config.py b/src/kiwi/subcommands/config.py new file mode 100644 index 0000000..15f3c9c --- /dev/null +++ b/src/kiwi/subcommands/config.py @@ -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 diff --git a/src/kiwi/subcommands/disable.py b/src/kiwi/subcommands/disable.py new file mode 100644 index 0000000..d4d762d --- /dev/null +++ b/src/kiwi/subcommands/disable.py @@ -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() diff --git a/src/kiwi/subcommands/down.py b/src/kiwi/subcommands/down.py new file mode 100644 index 0000000..cd53b3e --- /dev/null +++ b/src/kiwi/subcommands/down.py @@ -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 diff --git a/src/kiwi/subcommands/enable.py b/src/kiwi/subcommands/enable.py new file mode 100644 index 0000000..1262a7f --- /dev/null +++ b/src/kiwi/subcommands/enable.py @@ -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() diff --git a/src/kiwi/subcommands/inspect.py b/src/kiwi/subcommands/inspect.py new file mode 100644 index 0000000..a7c139e --- /dev/null +++ b/src/kiwi/subcommands/inspect.py @@ -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 diff --git a/src/kiwi/subcommands/logs.py b/src/kiwi/subcommands/logs.py new file mode 100644 index 0000000..2407220 --- /dev/null +++ b/src/kiwi/subcommands/logs.py @@ -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 diff --git a/src/kiwi/subcommands/new.py b/src/kiwi/subcommands/new.py new file mode 100644 index 0000000..5fc5243 --- /dev/null +++ b/src/kiwi/subcommands/new.py @@ -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 diff --git a/src/kiwi/subcommands/pull.py b/src/kiwi/subcommands/pull.py new file mode 100644 index 0000000..a1ed790 --- /dev/null +++ b/src/kiwi/subcommands/pull.py @@ -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 diff --git a/src/kiwi/subcommands/purge.py b/src/kiwi/subcommands/purge.py new file mode 100644 index 0000000..2200302 --- /dev/null +++ b/src/kiwi/subcommands/purge.py @@ -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 diff --git a/src/kiwi/subcommands/push.py b/src/kiwi/subcommands/push.py new file mode 100644 index 0000000..0f83c52 --- /dev/null +++ b/src/kiwi/subcommands/push.py @@ -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 diff --git a/src/kiwi/subcommands/shell.py b/src/kiwi/subcommands/shell.py new file mode 100644 index 0000000..6ae4a0f --- /dev/null +++ b/src/kiwi/subcommands/shell.py @@ -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 diff --git a/src/kiwi/subcommands/up.py b/src/kiwi/subcommands/up.py new file mode 100644 index 0000000..b369545 --- /dev/null +++ b/src/kiwi/subcommands/up.py @@ -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 diff --git a/src/kiwi/subcommands/update.py b/src/kiwi/subcommands/update.py new file mode 100644 index 0000000..0440cdc --- /dev/null +++ b/src/kiwi/subcommands/update.py @@ -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