diff --git a/README.md b/README.md index cc57404..fc0b2ac 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,15 @@ ![MIT](https://img.shields.io/github/license/carnival-org/carnival) -Software provisioning tool, built on top of [Fabric](http://www.fabfile.org/) +Software provisioning tool * Runs on MacOs and Linux * Tested on Ubuntu and CentOS +* Uses mypy strict typing mode [mypy.ini](mypy.ini) +* Safe, full run chain is validated before run + +# Example +See [carnival_tasks_example.py](carnival_tasks_example.py) ## Install ```bash diff --git a/carnival/contrib/__init__.py b/carnival/contrib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/carnival/contrib/steps/__init__.py b/carnival/contrib/steps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/carnival/contrib/steps/apt.py b/carnival/contrib/steps/apt.py new file mode 100644 index 0000000..3128839 --- /dev/null +++ b/carnival/contrib/steps/apt.py @@ -0,0 +1,238 @@ +import typing + +from colorama import Style as S, Fore as F # type: ignore + +from carnival import Step +from carnival import Connection +from carnival.steps import validators + + +class GetPackageVersions(Step): + """ + Получить список доступных версий пакета + """ + + def __init__(self, pkgname: str): + self.pkgname = pkgname + + def get_validators(self) -> typing.List[validators.StepValidatorBase]: + return [ + validators.CommandRequiredValidator('apt-cache'), + ] + + def run(self, c: Connection) -> typing.List[str]: + versions = [] + result = c.run(f"DEBIAN_FRONTEND=noninteractive apt-cache madison {self.pkgname}", hide=True, warn=True) + if result.ok is False: + return [] + + for line in result.stdout.strip().split("\n"): + n, ver, r = line.split("|") + versions.append(ver.strip()) + return versions + + +class GetInstalledPackageVersion(Step): + """ + Получить установленную версию пакета + + :return: Версия пакета если установлен, `None` если пакет не установлен + """ + + def __init__(self, pkgname: str): + self.pkgname = pkgname + + def get_validators(self) -> typing.List[validators.StepValidatorBase]: + return [ + validators.CommandRequiredValidator('dpkg'), + ] + + def run(self, c: Connection) -> typing.Optional[str]: + """ + :return: Версия пакета если установлен, `None` если пакет не установлен + """ + result = c.run( + f"DEBIAN_FRONTEND=noninteractive dpkg -l {self.pkgname} | grep '{self.pkgname}'", + hide=True, + warn=True, + ) + if result.ok is False: + return None + + installed, pkgn, ver, arch, *desc = result.stdout.strip().split("\n")[0].split() + if installed != 'ii': + return None + + assert isinstance(ver, str) + return ver.strip() + + +class IsPackageInstalled(Step): + """ + Проверить установлен ли пакет + Если версия не указана - проверяется любая + """ + + def __init__(self, pkgname: str, version: typing.Optional[str] = None) -> None: + self.pkgname = pkgname + self.version = version + self.get_installed_package_version = GetInstalledPackageVersion(pkgname=self.pkgname) + + def get_validators(self) -> typing.List[validators.StepValidatorBase]: + return self.get_installed_package_version.get_validators() + + def run(self, c: Connection) -> bool: + """ + Проверить установлен ли пакет + Если версия не указана - проверяется любая + """ + + pkgver = self.get_installed_package_version.run(c=c) + if self.version is None and pkgver is not None: + return True + + if self.version is not None and pkgver == self.version: + return True + + return False + + +class ForceInstall(Step): + """ + Установить пакет без проверки установлен ли он + """ + + def __init__(self, pkgname: str, version: typing.Optional[str] = None, update: bool = False, hide: bool = False): + self.pkgname = pkgname + self.version = version + self.update = update + self.hide = hide + + def get_validators(self) -> typing.List[validators.StepValidatorBase]: + return [ + validators.CommandRequiredValidator('apt-get'), + ] + + def run(self, c: Connection) -> None: + pkgname = self.pkgname + if self.version: + pkgname = f"{self.pkgname}={self.version}" + + if self.update: + c.run("DEBIAN_FRONTEND=noninteractive sudo apt-get update", hide=self.hide) + + c.run(f"DEBIAN_FRONTEND=noninteractive sudo apt-get install -y {pkgname}", hide=self.hide) + + +class Install(Step): + """ + Установить пакет если он еще не установлен в системе + """ + def __init__( + self, + pkgname: str, + version: typing.Optional[str] = None, + update: bool = True, + hide: bool = False, + ) -> None: + """ + :param pkgname: название пакета + :param version: версия + :param update: запустить apt-get update перед установкой + :param hide: скрыть вывод этапов + """ + self.pkgname = pkgname + self.version = version + self.update = update + self.hide = hide + + self.is_package_installed = IsPackageInstalled(pkgname=self.pkgname, version=self.version) + self.force_install = ForceInstall( + pkgname=self.pkgname, version=self.version, + update=self.update, hide=self.hide + ) + + def get_validators(self) -> typing.List[validators.StepValidatorBase]: + return self.is_package_installed.get_validators() + self.force_install.get_validators() + + def run(self, c: Connection) -> bool: + """ + :return: `True` если пакет был установлен, `False` если пакет уже был установлен ранее + """ + if self.is_package_installed.run(c=c): + if self.version is not None: + installed_version = GetInstalledPackageVersion(self.pkgname).run(c) + if installed_version is not None and self.version == installed_version: + return False + else: + return False + return False + + ForceInstall(pkgname=self.pkgname, version=self.version, update=self.update, hide=self.hide).run(c=c) + print(f"{S.BRIGHT}{self.pkgname}{S.RESET_ALL}: {F.YELLOW}installed{F.RESET}") + return True + + +class InstallMultiple(Step): + """ + Установить несколько пакетов, если они не установлены + """ + + def __init__(self, pkg_names: typing.List[str], update: bool = True, hide: bool = False) -> None: + """ + :param pkg_names: список пакетов которые нужно установить + :param update: запустить apt-get update перед установкой + :param hide: скрыть вывод этапов + """ + + self.pkg_names = pkg_names + self.update = update + self.hide = hide + + def get_validators(self) -> typing.List[validators.StepValidatorBase]: + return [ + validators.CommandRequiredValidator('apt-get'), + ] + + def run(self, c: Connection) -> bool: + """ + :return: `True` если хотя бы один пакет был установлен, `False` если все пакеты уже были установлен ранее + """ + if all([IsPackageInstalled(x).run(c=c) for x in self.pkg_names]): + return False + + if self.update: + c.run("DEBIAN_FRONTEND=noninteractive sudo apt-get update", hide=self.hide) + + for pkg in self.pkg_names: + Install(pkgname=pkg, update=False, hide=self.hide).run(c=c) + return True + + +class Remove(Step): + """ + Удалить пакет + """ + + def __init__(self, pkg_names: typing.List[str], hide: bool = False) -> None: + """ + :param pkg_names: список пакетов которые нужно удалить + :param hide: скрыть вывод этапов + """ + self.pkg_names = pkg_names + self.hide = hide + + def get_validators(self) -> typing.List[validators.StepValidatorBase]: + return [ + validators.InlineValidator( + if_err_true_fn=lambda c: not self.pkg_names, + error_message="'pkg_names' must not be empty", + ), + validators.CommandRequiredValidator('apt-get'), + ] + + def run(self, c: Connection) -> None: + c.run( + f"DEBIAN_FRONTEND=noninteractive sudo apt-get remove --auto-remove -y {' '.join(self.pkg_names)}", + hide=self.hide + ) diff --git a/carnival/contrib/steps/cli.py b/carnival/contrib/steps/cli.py new file mode 100644 index 0000000..6b88a98 --- /dev/null +++ b/carnival/contrib/steps/cli.py @@ -0,0 +1,20 @@ +import typing + +from carnival import Step, Connection + + +class OpenShell(Step): + """ + Запустить интерактивный шелл в папке + """ + + def __init__(self, shell_cmd: str = "/bin/bash", cwd: typing.Optional[str] = None) -> None: + """ + :param shell_cmd: команда для запуска шелла + :param cwd: папка + """ + self.shell_cmd = shell_cmd + self.cwd = cwd + + def run(self, c: "Connection") -> typing.Any: + c.run(self.shell_cmd, cwd=self.cwd) diff --git a/carnival/contrib/steps/docker.py b/carnival/contrib/steps/docker.py new file mode 100644 index 0000000..3bf57a1 --- /dev/null +++ b/carnival/contrib/steps/docker.py @@ -0,0 +1,119 @@ +import os +import typing + +from colorama import Fore as F, Style as S # type: ignore + +from carnival import Step +from carnival import Connection +from carnival.steps import validators, shortcuts + +from carnival.contrib.steps import apt, systemd + + +class CeInstallUbuntu(Step): + """ + Установить docker на ubuntu + https://docs.docker.com/engine/install/ubuntu/ + """ + def __init__(self, docker_version: typing.Optional[str] = None) -> None: + """ + :param docker_version: версия docker-ce + """ + self.docker_version = docker_version + + def get_validators(self) -> typing.List[validators.StepValidatorBase]: + return [ + validators.CommandRequiredValidator("apt-get"), + validators.CommandRequiredValidator("curl"), + ] + + def run(self, c: Connection) -> None: + pkgname = "docker-ce" + if apt.IsPackageInstalled(pkgname=pkgname, version=self.docker_version).run(c=c): + print(f"{S.BRIGHT}docker-ce{S.RESET_ALL}: {F.GREEN}already installed{F.RESET}") + + print(f"Installing {pkgname}...") + c.run("sudo apt-get update") + c.run("sudo apt-get install -y apt-transport-https ca-certificates curl software-properties-common") + c.run("curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -") + c.run('sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"') # noqa:E501 + + apt.ForceInstall(pkgname=pkgname, version=self.docker_version, update=True, hide=True).run(c=c) + print(f"{S.BRIGHT}docker-ce{S.RESET_ALL}: {F.YELLOW}installed{F.RESET}") + + +class ComposeInstall(Step): + """ + Установить docker-compose + """ + def __init__( + self, + version: str = "1.25.1", + dest: str = "/usr/bin/docker-compose", + ) -> None: + """ + :param version: версия compose + :param dest: папка для установки, позразумевается что она должна быт в $PATH + """ + self.version = version + self.dest = dest + + def get_validators(self) -> typing.List[validators.StepValidatorBase]: + return [ + validators.CommandRequiredValidator("curl"), + ] + + def run(self, c: Connection) -> None: + if shortcuts.is_cmd_exist(c, "docker-compose"): + print(f"{S.BRIGHT}docker-compose{S.RESET_ALL}: {F.GREEN}already installed{F.RESET}") + return + + link = f"https://github.com/docker/compose/releases/download/{self.version}/docker-compose-`uname -s`-`uname -m`" # noqa:501 + c.run(f"sudo curl -sL {link} -o {self.dest}") + c.run(f"sudo chmod a+x {self.dest}") + print(f"{S.BRIGHT}docker-compose{S.RESET_ALL}: {F.GREEN}already installed{F.RESET}") + + +class UploadImageFile(Step): + """ + Залить с локаьного диска tar-образ docker на сервер + и загрузить в демон командой `docker save image -o image.tar` + """ + def __init__( + self, + docker_image_path: str, + dest_dir: str = '/tmp/', + rm_after_load: bool = False, + rsync_opts: typing.Optional[typing.Dict[str, typing.Any]] = None, + ): + """ + :param docker_image_path: tar-образ docker + :param dest_dir: папка куда заливать + :param rm_after_load: удалить образ после загрузки + """ + if not dest_dir.endswith("/"): + dest_dir += "/" + + self.docker_image_path = docker_image_path + self.dest_dir = dest_dir + self.rm_after_load = rm_after_load + self.rsync_opts = rsync_opts or {} + + def get_name(self) -> str: + return f"{super().get_name()}(src={self.docker_image_path}, dst={self.dest_dir})" + + def get_validators(self) -> typing.List[validators.StepValidatorBase]: + return [ + validators.CommandRequiredValidator("systemctl"), + validators.CommandRequiredValidator("docker"), + ] + + def run(self, c: Connection) -> None: + image_file_name = os.path.basename(self.docker_image_path) + systemd.Start("docker").run(c=c) + + shortcuts.rsync(c.host, self.docker_image_path, self.dest_dir, **self.rsync_opts) + c.run(f"cd {self.dest_dir}; docker load -i {image_file_name}") + + if self.rm_after_load: + c.run(f"rm -rf {self.dest_dir}{image_file_name}") diff --git a/carnival/contrib/steps/docker_compose.py b/carnival/contrib/steps/docker_compose.py new file mode 100644 index 0000000..93c8e6a --- /dev/null +++ b/carnival/contrib/steps/docker_compose.py @@ -0,0 +1,247 @@ +import os +import typing +from itertools import chain + +from carnival import Connection +from carnival import Step +from carnival.steps import validators + +from carnival.contrib.steps import systemd, transfer + + +class UploadService(Step): + """ + Залить docker-compose сервис и запустить + """ + + def __init__( + self, + app_dir: str, + + template_files: typing.List[typing.Union[str, typing.Tuple[str, str]]], + template_context: typing.Dict[str, typing.Any], + ): + """ + :param app_dir: Путь до папки назначения + :param template_files: Список jinja2-шаблонов. Может быть списком файлов или кортежей (src, dst) + :param template_context: Контекст шаблонов, один на все шаблоны + """ + self.app_dir = app_dir + + self.template_files: typing.List[typing.Tuple[str, str]] = [] + for dest in template_files: + if isinstance(dest, str): + template_path = dest + dest_fname = os.path.basename(template_path) + elif isinstance(dest, tuple): + template_path, dest_fname = dest + else: + raise ValueError(f"Cant parse template_file definition: {dest}") + + self.template_files.append((template_path, dest_fname)) + + self.template_context = template_context + + self.transfer_chain = [] + for template_path, dest_fname in self.template_files: + self.transfer_chain.append(transfer.PutTemplate( + template_path=template_path, + remote_path=os.path.join(self.app_dir, dest_fname), + context=self.template_context, + )) + + def get_name(self) -> str: + return f"{super().get_name()}({self.app_dir})" + + def get_validators(self) -> typing.List[validators.StepValidatorBase]: + return [ + *list(chain(*[x.get_validators() for x in self.transfer_chain])), + validators.CommandRequiredValidator('docker'), + validators.CommandRequiredValidator('docker-compose'), + ] + + def run(self, c: Connection) -> typing.Any: + systemd.Start("docker").run(c=c) + + c.run(f"mkdir -p {self.app_dir}") + + for transfer_step in self.transfer_chain: + transfer_step.run(c) + + c.run("docker-compose rm -f", cwd=self.app_dir, hide=True) + + +class Up(Step): + def __init__( + self, + app_dir: str, + scale: typing.Optional[typing.Dict[str, int]] = None, + only: typing.Optional[typing.List[str]] = None + ): + """ + :param app_dir: Путь до папки назначения + :param scale: Масштабирование сервисов при запуске, не используется если `None` + :param only: Запустить только указанные сервисы, не используется если `None` + """ + self.app_dir = app_dir + self.scale = scale + self.only = only + + def get_name(self) -> str: + return f"{super().get_name()}({self.app_dir})" + + def get_validators(self) -> typing.List[validators.StepValidatorBase]: + return [ + validators.InlineValidator( + if_err_true_fn=lambda c: self.only == [], + error_message="'only' must not be empty list, use None to disable", + ), + validators.CommandRequiredValidator('docker'), + validators.CommandRequiredValidator('docker-compose'), + ] + + def run(self, c: Connection) -> typing.Any: + systemd.Start("docker").run(c=c) + + onlystr = "" + if self.only is not None: + onlystr = " ".join(self.only) + + if self.scale: + scale_str = " ".join([f" --scale {service_name}={count}" for service_name, count in self.scale.items()]) + else: + scale_str = "" + c.run(f"docker-compose up -d --remove-orphans {onlystr} {scale_str}", cwd=self.app_dir) + + +class Ps(Step): + """ + docker-compose ps + """ + + subcommand = "ps" + + def __init__(self, app_dir: str, flags: str = ""): + """ + :param app_dir: Application remote directory + """ + self.app_dir = app_dir + self.flags = flags + + def get_name(self) -> str: + return f"{super().get_name()}({self.app_dir})" + + def get_validators(self) -> typing.List[validators.StepValidatorBase]: + return [ + validators.CommandRequiredValidator('docker-compose'), + ] + + def run(self, c: Connection) -> typing.Any: + c.run(f"docker-compose {self.subcommand} {self.flags}", cwd=self.app_dir) + + +class Restart(Ps): + """ + docker-compose restart + """ + + subcommand = "restart" + + +class RestartServices(Step): + """ + docker-compose restart [services...] + """ + + subcommand = "restart" + + def __init__(self, app_dir: str, services: typing.List[str]): + """ + :param app_dir: Application remote directory + """ + self.app_dir = app_dir + self.services = " ".join(services) + self.services = self.services.strip() + + def get_name(self) -> str: + return f"{super().get_name()}(services='{self.services}')" + + def get_validators(self) -> typing.List[validators.StepValidatorBase]: + return [ + validators.InlineValidator( + if_err_true_fn=lambda c: not self.services, + error_message="'services' must not be empty", + ), + validators.CommandRequiredValidator('docker-compose'), + ] + + def run(self, c: Connection) -> typing.Any: + c.run(f"docker-compose {self.subcommand} {self.services}", cwd=self.app_dir) + + +class Stop(Ps): + """ + docker-compose stop + """ + subcommand = "stop" + + +class StopServices(RestartServices): + """ + docker-compose logs -f --tail=tail + """ + subcommand = "stop" + + +class Logs(Step): + """ + docker-compose restart [services...] + """ + + def __init__(self, app_dir: str, tail: int = 20): + """ + :param app_dir: Application remote directory + """ + self.app_dir = app_dir + self.tail = tail + + def get_name(self) -> str: + return f"{super().get_name()}({self.app_dir})" + + def get_validators(self) -> typing.List[validators.StepValidatorBase]: + return [ + validators.CommandRequiredValidator('docker-compose'), + ] + + def run(self, c: Connection) -> typing.Any: + c.run(f"docker-compose logs -f --tail={self.tail}", cwd=self.app_dir, hide=False) + + +class LogsServices(Step): + """ + docker-compose logs -f --tail=tail [services...] + """ + + def __init__(self, app_dir: str, services: typing.List[str], tail: int = 20): + """ + :param app_dir: Application remote directory + """ + self.app_dir = app_dir + self.services = " ".join(services) + self.services = self.services.strip() + self.tail = tail + + def get_name(self) -> str: + return f"{super().get_name()}(services='{self.services}')" + + def get_validators(self) -> typing.List[validators.StepValidatorBase]: + return [ + validators.InlineValidator( + if_err_true_fn=lambda c: not self.services, + error_message="'services' must not be empty", + ), + validators.CommandRequiredValidator('docker-compose'), + ] + + def run(self, c: Connection) -> typing.Any: + c.run(f"docker-compose logs -f --tail={self.tail} {self.services}", cwd=self.app_dir) diff --git a/carnival/contrib/steps/fs.py b/carnival/contrib/steps/fs.py new file mode 100644 index 0000000..50d1eaf --- /dev/null +++ b/carnival/contrib/steps/fs.py @@ -0,0 +1,25 @@ +import typing + +from carnival import Step, Connection +from carnival.steps import validators + + +class Mkdirs(Step): + """ + Создать папки + """ + + def __init__(self, paths: typing.List[str]): + """ + :param paths: папки + """ + self.paths = paths + + def get_validators(self) -> typing.List[validators.StepValidatorBase]: + return [ + validators.CommandRequiredValidator("mkdir") + ] + + def run(self, c: Connection) -> None: + for path in self.paths: + c.run(f"mkdir -p {path}") diff --git a/carnival/contrib/steps/ssh.py b/carnival/contrib/steps/ssh.py new file mode 100644 index 0000000..baa7a91 --- /dev/null +++ b/carnival/contrib/steps/ssh.py @@ -0,0 +1,78 @@ +import os +import re + +from carnival import Step +from carnival import Connection + + +def _escape_for_regex(text: str) -> str: + """ + Tnx to https://stackoverflow.com/questions/280435/escaping-regex-string + :param text: + :return: + """ + regex = re.escape(text) + # double escaping for \ + regex = regex.replace("\\\\", "\\\\\\") + # triple-escaping for $ signs + regex = regex.replace(r"\$", r"\\\$") + # single quotes should not be escaped + regex = regex.replace(r"\'", "'") + return regex + + +def _is_file_contains(c: Connection, filename: str, text: str, escape: bool = True) -> bool: + """ + Содержит ли файл текст + + :param c: Конект с хостом + :param filename: путь до файла + :param text: текст который нужно искать + :param escape: экранировать ли текст + """ + if escape: + text = _escape_for_regex(text) + egrep_cmd = 'egrep "{}" "{}"'.format(text, filename) + return c.run(egrep_cmd, hide=True, warn=True).ok + + +class AddAuthorizedKey(Step): + """ + Добавить ssh ключ в `authorized_keys` если его там нет + """ + + def __init__(self, ssh_key: str, keys_file: str = ".ssh/authorized_keys") -> None: + """ + :param ssh_key: ключ + :param keys_file: пусть до файла `authorized_keys` + :return: `True` если ключ был добавлен, `False` если ключ уже был в файле + """ + self.ssh_key = ssh_key.strip() + self.keys_file = keys_file + + def run(self, c: Connection) -> bool: + c.run("mkdir -p ~/.ssh") + c.run("chmod 700 ~/.ssh") + c.run(f"touch {self.keys_file}") + + if not _is_file_contains(c, self.keys_file, self.ssh_key, escape=True): + c.run(f"echo '{self.ssh_key}' >> {self.keys_file}") + return True + return False + + +class CopyId(Step): + """ + Добавить публичный ssh-ключ текущего пользователя в авторизованные + """ + + def __init__(self, pubkey_file: str = "~/.ssh/id_rsa.pub") -> None: + """ + :param pubkey_file: путь до файла с публичным ключем + :return: `True` если ключ был добавлен, `False` если ключ уже был в файле + """ + self.pubkey_file = pubkey_file + + def run(self, c: Connection) -> bool: + key = open(os.path.expanduser(self.pubkey_file)).read().strip() + return AddAuthorizedKey(key).run(c=c) diff --git a/carnival/contrib/steps/systemd.py b/carnival/contrib/steps/systemd.py new file mode 100644 index 0000000..0a0aab5 --- /dev/null +++ b/carnival/contrib/steps/systemd.py @@ -0,0 +1,116 @@ +from carnival import Step +from carnival import Connection + + +class DaemonReload(Step): + """ + Перегрузить systemd + """ + + def run(self, c: Connection) -> None: + c.run("sudo systemctl --system daemon-reload") + + +class Start(Step): + """ + Запустить сервис + """ + def __init__(self, service_name: str, reload_daemon: bool = False) -> None: + """ + :param service_name: имя сервиса + :param reload_daemon: перегрузить systemd + """ + self.service_name = service_name + self.reload_daemon = reload_daemon + + def run(self, c: Connection) -> None: + if self.reload_daemon: + DaemonReload().run(c=c) + + c.run(f"sudo systemctl start {self.service_name}") + + +class Stop(Step): + """ + Остановить сервис + """ + + def __init__(self, service_name: str, reload_daemon: bool = False) -> None: + """ + :param service_name: имя сервиса + :param reload_daemon: перегрузить systemd + """ + self.service_name = service_name + self.reload_daemon = reload_daemon + + def run(self, c: Connection) -> None: + if self.reload_daemon: + DaemonReload().run(c=c) + + c.run(f"sudo systemctl stop {self.service_name}") + + +class Restart(Step): + """ + Перезапустить сервис + """ + + def __init__(self, service_name: str) -> None: + """ + :param service_name: имя сервиса + """ + self.service_name = service_name + + def run(self, c: Connection) -> None: + c.run(f"sudo systemctl restart {self.service_name}") + + +class Enable(Step): + """ + Добавить сервис в автозапуск + """ + + def __init__(self, service_name: str, reload_daemon: bool = False, start_now: bool = True) -> None: + """ + :param service_name: имя сервиса + :param reload_daemon: перегрузить systemd + :param start_now: запустить сервис после добавления + """ + self.service_name = service_name + self.reload_daemon = reload_daemon + self.start_now = start_now + + def run(self, c: Connection) -> None: + if self.reload_daemon: + DaemonReload().run(c=c) + + c.run(f"sudo systemctl enable {self.service_name}") + + if self.start_now: + Start(self.service_name).run(c=c) + + +class Disable(Step): + """ + Убрать сервис из автозапуска + """ + + def __init__(self, service_name: str, reload_daemon: bool = False, stop_now: bool = True) -> None: + """ + :param service_name: имя сервиса + :param reload_daemon: перегрузить systemd + :param stop_now: Остановить сервис + """ + + self.service_name = service_name + self.reload_daemon = reload_daemon + self.stop_now = stop_now + + def run(self, c: Connection) -> None: + if self.reload_daemon: + DaemonReload().run(c=c) + + c.run(f"sudo systemctl disable {self.service_name}") + + if self.stop_now: + Stop(self.service_name).run(c=c) diff --git a/carnival/contrib/steps/transfer.py b/carnival/contrib/steps/transfer.py new file mode 100644 index 0000000..8ae2f68 --- /dev/null +++ b/carnival/contrib/steps/transfer.py @@ -0,0 +1,194 @@ +import os.path +import typing +from io import BytesIO +from hashlib import sha1 + +from tqdm import tqdm # type: ignore +from colorama import Style as S, Fore as F # type: ignore + +from carnival import Connection, localhost_connection +from carnival.templates import render +from carnival.steps import shortcuts, Step, validators + + +def _file_sha1sum(c: Connection, fpath: str) -> typing.Optional[str]: + if not shortcuts.is_file(c, fpath): + return None + return c.run(f"cat {fpath} | shasum -a1", hide=True).stdout.strip(" -\t\n") + + +def _transfer_file( + reader: typing.IO[bytes], + writer: typing.IO[bytes], + dst_file_size: int, + dst_file_path: str, + bufsize: int = 32768, +) -> None: + write_size = 0 + + with tqdm( + desc=f"Transferring {dst_file_path}", + unit='B', unit_scale=True, total=dst_file_size, + leave=False, + ) as pbar: + while True: + data = reader.read(bufsize) + if len(data) == 0: + break + + writer.write(data) + pbar.update(len(data)) + write_size += len(data) + + if dst_file_size != write_size: + raise IOError(f"size mismatch! {dst_file_size} != {write_size}") + + +class GetFile(Step): + """ + Скачать файл с удаленного сервера на локальный диск + """ + def __init__(self, remote_path: str, local_path: str): + """ + :param remote_path: Путь до файла на сервере + :param local_path: Локальный путь назначения + """ + self.remote_path = remote_path + self.local_path = local_path + + def get_name(self) -> str: + return f"{super().get_name()}(remote_path={self.remote_path}, local_path={self.local_path})" + + def get_validators(self) -> typing.List["validators.StepValidatorBase"]: + return [ + validators.IsFileValidator(self.remote_path), + validators.Not( + validators.IsDirectoryValidator(self.local_path, on_localhost=True), + error_message=f"{self.remote_path} must be full file path, not directory", + ) + ] + + def run(self, c: "Connection") -> None: + remote_sha1 = _file_sha1sum(c, self.remote_path) + local_sha1 = _file_sha1sum(localhost_connection, self.local_path) + if remote_sha1 is not None and local_sha1 is not None: + if remote_sha1 == local_sha1: + return + + # Create dirs if needed + dirname = os.path.dirname(self.local_path) + if dirname: + localhost_connection.run(f"mkdir -p {dirname}", hide=True) + + with localhost_connection.file_write(self.local_path) as writer: + with c.file_read(self.remote_path) as reader: + dst_file_size = c.file_stat(self.remote_path).st_size + _transfer_file( + reader=reader, writer=writer, + dst_file_size=dst_file_size, dst_file_path=self.remote_path, + ) + + print(f"{S.BRIGHT}{self.remote_path}{S.RESET_ALL}: {F.YELLOW}downloaded{F.RESET}") + + +class PutFile(Step): + """ + Закачать файл на сервер + + """ + def __init__(self, local_path: str, remote_path: str, ): + """ + :param local_path: путь до локального файла + :param remote_path: путь куда сохранить на сервере + """ + self.local_path = local_path + self.remote_path = remote_path + + def get_name(self) -> str: + return f"{super().get_name()}(local_path={self.local_path}, remote_path={self.remote_path})" + + def get_validators(self) -> typing.List["validators.StepValidatorBase"]: + return [ + validators.IsFileValidator(self.local_path, on_localhost=True), + validators.Not( + validators.IsDirectoryValidator(self.remote_path), + error_message=f"{self.remote_path} must be full file path, not directory", + ) + ] + + def run(self, c: "Connection") -> None: + remote_sha1 = _file_sha1sum(c, self.remote_path) + local_sha1 = _file_sha1sum(localhost_connection, self.local_path) + if remote_sha1 is not None and local_sha1 is not None: + if remote_sha1 == local_sha1: + return + + # Create dirs if needed + dirname = os.path.dirname(self.remote_path) + if dirname: + c.run(f"mkdir -p {dirname}", hide=True) + + with localhost_connection.file_read(self.local_path) as reader: + with c.file_write(self.remote_path) as writer: + dst_file_size = localhost_connection.file_stat(self.local_path).st_size + _transfer_file( + reader=reader, writer=writer, + dst_file_size=dst_file_size, dst_file_path=self.remote_path, + ) + + print(f"{S.BRIGHT}{self.remote_path}{S.RESET_ALL}: {F.YELLOW}uploaded{F.RESET}") + + +class PutTemplate(Step): + """ + Отрендерить файл с помощью jinja-шаблонов и закачать на сервер + См раздел templates. + """ + + def __init__(self, template_path: str, remote_path: str, context: typing.Dict[str, typing.Any]): + """ + :param template_path: путь до локального файла jinja + :param remote_path: путь куда сохранить на сервере + :param context: контекс для рендеринга jinja2 + """ + self.template_path = template_path + self.remote_path = remote_path + self.context = context + + def get_name(self) -> str: + return f"{super().get_name()}(template_path={self.template_path})" + + def get_validators(self) -> typing.List["validators.StepValidatorBase"]: + return [ + validators.TemplateValidator(self.template_path, context=self.context), + ] + + def run(self, c: "Connection") -> None: + filebytes = render(template_path=self.template_path, **self.context).encode() + + remote_sha1 = _file_sha1sum(c, self.remote_path) + local_sha1 = sha1(filebytes).hexdigest() + + if remote_sha1 is not None and local_sha1 is not None: + if remote_sha1 == local_sha1: + return + + # Create dirs if needed + dirname = os.path.dirname(self.remote_path) + if dirname: + c.run(f"mkdir -p {dirname}", hide=True) + + with c.file_write(self.remote_path) as writer: + _transfer_file( + reader=BytesIO(filebytes), writer=writer, + dst_file_size=len(filebytes), dst_file_path=self.remote_path, + ) + + print(f"{S.BRIGHT}{self.remote_path}{S.RESET_ALL}: {F.YELLOW}uploaded{F.RESET}") + + +__all__ = ( + "GetFile", + "PutFile", + "PutTemplate", +) diff --git a/carnival/hosts/base.py b/carnival/hosts/base.py index 75f6eb8..854ea9a 100644 --- a/carnival/hosts/base.py +++ b/carnival/hosts/base.py @@ -3,28 +3,59 @@ import socket import abc from dataclasses import dataclass -from invoke.context import Result as InvokeResult # type: ignore -@dataclass +class CommandError(BaseException): + pass + + class Result: """ Результат выполнения команды """ - return_code: int - ok: bool - stdout: str - stderr: str + def __init__( + self, + return_code: int, + stderr: str, + stdout: str, + + command: str, + hide: bool, + warn: bool, + ): + """ + :param return_code: код возврата + :param output: комбинированный вывод stdout & stderr + :param command: команда, которая была запущена + :param hide: не показывать вывод в консоли + :param warn: вывести результат неуспешной команды вместо того чтобы выкинуть исключение :py:exc:`.CommandError` + """ + self.return_code = return_code + self.stderr = stderr.strip() + self.stdout = stdout.strip() - @classmethod - def from_invoke_result(cls, invoke_result: InvokeResult) -> "Result": - return Result( - return_code=invoke_result.exited, - ok=invoke_result.ok, - stdout=invoke_result.stdout.replace("\r", ""), - stderr=invoke_result.stderr.replace("\r", ""), - ) + if not self.ok or len(self.stderr): + if not warn: + if self.stderr: + print(stderr) + raise CommandError(f"{command} failed with exist code: {return_code}") + + if not hide: + print(self.stdout) + + @property + def ok(self) -> bool: + return self.return_code == 0 + + +@dataclass +class StatResult: + st_mode: int + st_size: int + st_uid: int + st_gid: int + st_atime: float class Connection: @@ -33,7 +64,7 @@ class Connection: Хост с которым связан конект """ - def __init__(self, host: "Host") -> None: + def __init__(self, host: "Host", run_timeout: int = 120) -> None: """ Конекст с хостом, все конекты являются контекст-менеджерами @@ -42,6 +73,7 @@ def __init__(self, host: "Host") -> None: """ self.host = host + self.run_timeout = run_timeout def __enter__(self) -> "Connection": raise NotImplementedError @@ -66,6 +98,32 @@ def run( :param cwd: Перейти в папку при выполнении команды """ + @abc.abstractmethod + def file_stat(self, path: str) -> StatResult: + """ + Получить fstat файла + + :param path: путь до файла + """ + + @abc.abstractmethod + def file_read(self, path: str) -> typing.ContextManager[typing.IO[bytes]]: + """ + Открыть файл на чтение + + :param path: путь до файла + :return: дескриптор файла + """ + + @abc.abstractmethod + def file_write(self, path: str) -> typing.ContextManager[typing.IO[bytes]]: + """ + Открыть файл на запись + + :param path: путь до файла + :return: дескриптор файла + """ + class Host: addr: str = "" diff --git a/carnival/hosts/local.py b/carnival/hosts/local.py index b5626f0..fc320f5 100644 --- a/carnival/hosts/local.py +++ b/carnival/hosts/local.py @@ -1,43 +1,55 @@ import typing -from invoke.context import Context # type: ignore +import os +from subprocess import Popen, PIPE from carnival.hosts import base class LocalConnection(base.Connection): - def __init__(self, host: base.Host) -> None: - super().__init__(host) - - self._c: typing.Optional[Context] = None - def __enter__(self) -> base.Connection: - self._c = Context() return self def __exit__(self, *args: typing.Any) -> None: - self._c = None + pass def run( self, command: str, - hide: bool = False, warn: bool = False, cwd: typing.Optional[str] = None, + hide: bool = False, + warn: bool = False, + cwd: typing.Optional[str] = None, ) -> base.Result: - assert self._c is not None, "Connection is not open" + with Popen(command, shell=True, stderr=PIPE, stdin=PIPE, stdout=PIPE, cwd=cwd) as proc: + retcode = proc.wait(timeout=self.run_timeout) + + assert proc.stdout is not None + assert proc.stderr is not None - handler_kwargs = { - "command": command, - "hide": hide, - "pty": True, - "warn": warn, - } + return base.Result( + return_code=retcode, + stdout=proc.stdout.read().decode().replace("\r", ""), + stderr=proc.stderr.read().decode().replace("\r", ""), - handler = self._c.run + command=command, + hide=hide, + warn=warn, + ) - if cwd is not None: - with self._c.cd(cwd): - return base.Result.from_invoke_result(handler(**handler_kwargs)) + def file_stat(self, path: str) -> base.StatResult: + stat = os.stat(path) + return base.StatResult( + st_mode=stat.st_mode, + st_size=stat.st_size, + st_uid=stat.st_uid, + st_gid=stat.st_gid, + st_atime=stat.st_atime, + ) - return base.Result.from_invoke_result(handler(**handler_kwargs)) + def file_read(self, path: str) -> typing.ContextManager[typing.IO[bytes]]: + return open(path, 'rb') + + def file_write(self, path: str) -> typing.ContextManager[typing.IO[bytes]]: + return open(path, 'wb') class LocalHost(base.Host): @@ -50,15 +62,6 @@ class LocalHost(base.Host): Адрес хоста, всегда `localhost` """ - def __init__( - self, - ) -> None: - """ - :param context: Контекст хоста - """ - super().__init__() - self.addr = "local" - def connect(self) -> LocalConnection: return LocalConnection(host=self) diff --git a/carnival/hosts/ssh.py b/carnival/hosts/ssh.py index 395b8a7..1a486ed 100644 --- a/carnival/hosts/ssh.py +++ b/carnival/hosts/ssh.py @@ -1,7 +1,8 @@ import typing +from contextlib import contextmanager -from paramiko.client import MissingHostKeyPolicy, AutoAddPolicy -from fabric.connection import Connection as FabricConnection # type: ignore +from paramiko.client import MissingHostKeyPolicy, AutoAddPolicy, SSHClient +from paramiko.config import SSH_PORT from carnival.hosts import base @@ -10,61 +11,119 @@ class SshConnection(base.Connection): def __init__( self, host: "SshHost", + ssh_addr: str, + ssh_port: int = SSH_PORT, + ssh_user: typing.Optional[str] = None, + ssh_password: typing.Optional[str] = None, + ssh_gateway: typing.Optional["SshHost"] = None, + ssh_connect_timeout: int = 10, + missing_host_key_policy: typing.Type[MissingHostKeyPolicy] = AutoAddPolicy, + run_timeout: int = 120, ) -> None: - super().__init__(host) + super().__init__(host, run_timeout=run_timeout) self.host: "SshHost" = host - self._c: typing.Optional[FabricConnection] = None - self._gateway: typing.Optional[FabricConnection] = None + self.ssh_gateway = ssh_gateway + self.ssh_addr = ssh_addr + self.ssh_port = ssh_port + self.ssh_user = ssh_user + self.ssh_password = ssh_password + self.ssh_connect_timeout = ssh_connect_timeout + + self.gw_conn: typing.Optional[SshConnection] = None + self.conn = SSHClient() + self.conn.load_system_host_keys() + self.conn.set_missing_host_key_policy(missing_host_key_policy) def __enter__(self) -> "SshConnection": - if self.host.ssh_gateway is not None: - self._gateway = self.host.ssh_gateway.connect().__enter__()._c - - self._c = FabricConnection( - host=self.host.addr, - port=self.host.ssh_port, - user=self.host.ssh_user, - connect_timeout=self.host.ssh_connect_timeout, - gateway=self._gateway, - connect_kwargs={ - 'password': self.host.ssh_password, - } + sock = None + if self.ssh_gateway: + self.gw_conn = self.ssh_gateway.connect() + transport = self.gw_conn.conn.get_transport() + assert transport is not None + sock = transport.open_channel('direct-tcpip', (self.ssh_addr, self.ssh_port), ('', 0)) + + self.conn.connect( + hostname=self.ssh_addr, + port=self.ssh_port, + username=self.ssh_user, + password=self.ssh_password, + timeout=self.ssh_connect_timeout, + sock=sock, # type: ignore ) - self._c.client.set_missing_host_key_policy(self.host.missing_host_key_policy) return self def __exit__(self, *args: typing.Any) -> None: - assert self._c is not None - self._c.close() - - if self._gateway is not None: - self._gateway.close() + self.conn.close() + if self.gw_conn is not None: + self.gw_conn.conn.close() def run( self, command: str, hide: bool = False, warn: bool = False, cwd: typing.Optional[str] = None, ) -> base.Result: - assert self._c is not None, "Connection is not created" - # lazy connect - if self._c.is_connected is False: - self._c.open() + assert self.conn is not None + initial_command = command - handler_kwargs = { - "command": command, - "hide": hide, - "pty": True, - "warn": warn, - } + if cwd is not None: + command = f"cd {cwd}; {command}" - handler = self._c.run + stdin, stdout, stderr = self.conn.exec_command( + command, + timeout=self.run_timeout, + get_pty=False, # Combines stdout and stderr, we dont want it + ) + retcode = stdout.channel.recv_exit_status() + + stdout_str = stdout.read().decode().replace("\r", "") + stderr_str = stderr.read().decode().replace("\r", "") + stdout.close() + stderr.close() + stdin.close() + + return base.Result( + return_code=retcode, + stdout=stdout_str, + stderr=stderr_str, + + command=initial_command, + hide=hide, + warn=warn, + ) - if cwd is not None: - with self._c.cd(cwd): - return base.Result.from_invoke_result(handler(**handler_kwargs)) + def file_stat(self, path: str) -> base.StatResult: + sftp = self.conn.open_sftp() + + stat = sftp.stat(path) + assert stat.st_mode is not None + assert stat.st_size is not None + assert stat.st_uid is not None + assert stat.st_gid is not None + assert stat.st_atime is not None + + return base.StatResult( + st_mode=stat.st_mode, + st_size=stat.st_size, + st_uid=stat.st_uid, + st_gid=stat.st_gid, + st_atime=stat.st_atime, + ) - return base.Result.from_invoke_result(handler(**handler_kwargs)) + @contextmanager + def file_read(self, path: str) -> typing.Generator[typing.IO[bytes], None, None]: + sftp = self.conn.open_sftp() + with sftp.open(path, 'rb') as reader: + typed_reader = typing.cast(typing.IO[bytes], reader) + yield typed_reader + + @contextmanager + def file_write(self, path: str) -> typing.Generator[typing.IO[bytes], None, None]: + with self as conn: + sftp = conn.conn.open_sftp() + with sftp.open(path, 'wb') as writer: + typed_writer = typing.cast(typing.IO[bytes], writer) + yield typed_writer class SshHost(base.Host): @@ -81,7 +140,8 @@ def __init__( self, addr: str, - ssh_user: typing.Optional[str] = None, ssh_password: typing.Optional[str] = None, + ssh_user: typing.Optional[str] = None, + ssh_password: typing.Optional[str] = None, ssh_port: int = 22, ssh_gateway: typing.Optional['SshHost'] = None, ssh_connect_timeout: int = 10, @@ -111,4 +171,16 @@ def __init__( self.missing_host_key_policy = missing_host_key_policy def connect(self) -> SshConnection: - return SshConnection(host=self) + """ + :returns: Возвращает контекстменеджер соединения для хоста + """ + return SshConnection( + host=self, + ssh_addr=self.addr, + ssh_port=self.ssh_port, + ssh_user=self.ssh_user, + ssh_password=self.ssh_password, + ssh_gateway=self.ssh_gateway, + ssh_connect_timeout=self.ssh_connect_timeout, + missing_host_key_policy=self.missing_host_key_policy, + ) diff --git a/carnival_tasks_example.py b/carnival_tasks_example.py index 3b9d636..9c9ae15 100644 --- a/carnival_tasks_example.py +++ b/carnival_tasks_example.py @@ -6,51 +6,29 @@ import typing import os -from carnival import TaskBase, SshHost, Step, Task, Connection, Role +from carnival import SshHost, Task, Role +from carnival.steps import Step +from carnival.contrib.steps import apt +# Define role class PackagesRole(Role): packages = ['htop', "mc"] +# Define host my_server_ip = os.getenv("TESTSERVER_ADDR", "1.2.3.4") # Dynamic ip for testing my_server = SshHost(my_server_ip, ssh_user="root") -PackagesRole(my_server) # Bind server to role - - -class CheckDiskSpace(TaskBase): - help = "Print server root disk usage" - - def run(self, disk: str = "/") -> None: - with my_server.connect() as c: - c.run(f"df -h {disk}", hide=False) - - -class InstallStep(Step): - def __init__(self, packages: typing.List[str], update: bool = True) -> None: - self.packages = " ".join(packages) - self.packages = self.packages.strip() - self.update = update - - def validate(self, c: Connection) -> typing.List[str]: - errors = [] - if not self.packages: - errors.append("packages cant be empty!") - - return errors - - def run(self, c: Connection) -> None: - if self.update: - c.run("apt-get update") - - c.run(f"apt-get install -y {self.packages}") +# Assign host to role +PackagesRole(my_server) +# Create task for role class InstallPackages(Task[PackagesRole]): help = "Install packages" def get_steps(self) -> typing.List[Step]: return [ - InstallStep(packages=self.role.packages), + apt.InstallMultiple(self.role.packages) ] diff --git a/poetry.lock b/poetry.lock index 764af94..8cfda5d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -145,23 +145,6 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -[[package]] -name = "fabric" -version = "2.6.0" -description = "High level SSH command execution" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -invoke = ">=1.3,<2.0" -paramiko = ">=2.4" -pathlib2 = "*" - -[package.extras] -pytest = ["mock (>=2.0.0,<3.0)", "pytest (>=3.2.5,<4.0)"] -testing = ["mock (>=2.0.0,<3.0)"] - [[package]] name = "flake8" version = "4.0.1" @@ -199,14 +182,6 @@ category = "dev" optional = false python-versions = "*" -[[package]] -name = "invoke" -version = "1.6.0" -description = "Pythonic task execution" -category = "main" -optional = false -python-versions = "*" - [[package]] name = "jinja2" version = "3.0.3" @@ -292,17 +267,6 @@ ed25519 = ["pynacl (>=1.0.1)", "bcrypt (>=3.1.3)"] gssapi = ["pyasn1 (>=0.1.7)", "gssapi (>=1.4.1)", "pywin32 (>=2.1.8)"] invoke = ["invoke (>=1.3)"] -[[package]] -name = "pathlib2" -version = "2.3.6" -description = "Object-oriented filesystem paths" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -six = "*" - [[package]] name = "pluggy" version = "1.0.0" @@ -603,6 +567,22 @@ category = "dev" optional = false python-versions = ">=3.6" +[[package]] +name = "tqdm" +version = "4.62.3" +description = "Fast, Extensible Progress Meter" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["py-make (>=0.1.0)", "twine", "wheel"] +notebook = ["ipywidgets (>=6)"] +telegram = ["requests"] + [[package]] name = "types-cryptography" version = "3.3.9" @@ -666,7 +646,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "d74b09e76afcac1255f41516b6f3e4e4a5425f54fa06ca2aac30b1d87f77108b" +content-hash = "a4e6e2920f25affa7e6a546363d4be92071a3dcb4f7828a5212e4913666cfea5" [metadata.files] alabaster = [ @@ -838,10 +818,6 @@ docutils = [ {file = "docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61"}, {file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"}, ] -fabric = [ - {file = "fabric-2.6.0-py2.py3-none-any.whl", hash = "sha256:7a71714b8b8f28cf828eceb155196f43ebac1bd4c849b7161ed5993d1cbcaa40"}, - {file = "fabric-2.6.0.tar.gz", hash = "sha256:47f184b070272796fd2f9f0436799e18f2ccba4ee8ee587796fca192acd46cd2"}, -] flake8 = [ {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"}, {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"}, @@ -858,11 +834,6 @@ iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] -invoke = [ - {file = "invoke-1.6.0-py2-none-any.whl", hash = "sha256:e6c9917a1e3e73e7ea91fdf82d5f151ccfe85bf30cc65cdb892444c02dbb5f74"}, - {file = "invoke-1.6.0-py3-none-any.whl", hash = "sha256:769e90caeb1bd07d484821732f931f1ad8916a38e3f3e618644687fc09cb6317"}, - {file = "invoke-1.6.0.tar.gz", hash = "sha256:374d1e2ecf78981da94bfaf95366216aaec27c2d6a7b7d5818d92da55aa258d3"}, -] jinja2 = [ {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"}, {file = "Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"}, @@ -979,10 +950,6 @@ paramiko = [ {file = "paramiko-2.8.1-py2.py3-none-any.whl", hash = "sha256:7b5910f5815a00405af55da7abcc8a9e0d9657f57fcdd9a89894fdbba1c6b8a8"}, {file = "paramiko-2.8.1.tar.gz", hash = "sha256:85b1245054e5d7592b9088cc6d08da22445417912d3a3e48138675c7a8616438"}, ] -pathlib2 = [ - {file = "pathlib2-2.3.6-py2.py3-none-any.whl", hash = "sha256:3a130b266b3a36134dcc79c17b3c7ac9634f083825ca6ea9d8f557ee6195c9c8"}, - {file = "pathlib2-2.3.6.tar.gz", hash = "sha256:7d8bcb5555003cdf4a8d2872c538faa3a0f5d20630cb360e518ca3b981795e5f"}, -] pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, @@ -1099,6 +1066,10 @@ tomli = [ {file = "tomli-1.2.2-py3-none-any.whl", hash = "sha256:f04066f68f5554911363063a30b108d2b5a5b1a010aa8b6132af78489fe3aade"}, {file = "tomli-1.2.2.tar.gz", hash = "sha256:c6ce0015eb38820eaf32b5db832dbc26deb3dd427bd5f6556cf0acac2c214fee"}, ] +tqdm = [ + {file = "tqdm-4.62.3-py2.py3-none-any.whl", hash = "sha256:8dd278a422499cd6b727e6ae4061c40b48fce8b76d1ccbf5d34fca9b7f925b0c"}, + {file = "tqdm-4.62.3.tar.gz", hash = "sha256:d359de7217506c9851b7869f3708d8ee53ed70a1b8edbba4dbcb47442592920d"}, +] types-cryptography = [ {file = "types-cryptography-3.3.9.tar.gz", hash = "sha256:39b8ce64fe89a7e31ea49030ee19ad81724c38ca7283e2c1f91b2ecfeea8b102"}, {file = "types_cryptography-3.3.9-py3-none-any.whl", hash = "sha256:2127dda3016ba9a6e518a76dec46a009a7da112ace79fc6b590fd028fa24db6b"}, diff --git a/pyproject.toml b/pyproject.toml index 0b8054b..76fd19c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,19 +1,19 @@ [tool.poetry] name = "carnival" version = "4.0.0" -description = "Fabric-based software provisioning tool" +description = "Software provisioning tool" authors = ["Dmirty Simonov "] license = "MIT" include = ["carnival/py.typed"] [tool.poetry.dependencies] python = "^3.8" -Fabric = "2.6.0" -invoke = "1.6.0" Jinja2 = "3.0.3" Click = "8.0.3" python-dotenv = "0.19.2" colorama = "^0.4.4" +tqdm = "^4.62.3" +paramiko = "^2.8.1" [tool.poetry.dev-dependencies] pytest = "^6.2.5"