├── miniboss ├── py.typed ├── __init__.py ├── exceptions.py ├── running_context.py ├── context.py ├── main.py ├── types.py ├── docker_client.py ├── service_agent.py └── services.py ├── setup.cfg ├── tests ├── pytest.ini ├── unit │ ├── test_types.py │ ├── test_context.py │ ├── test_main.py │ ├── common.py │ ├── test_running_context.py │ ├── test_service_agent.py │ └── test_services.py └── integration │ └── test_docker_client.py ├── sample-apps ├── requirements.txt ├── python-todo │ ├── requirements.txt │ ├── Dockerfile │ ├── templates │ │ └── index.html │ └── app.py └── miniboss-main.py ├── .gitignore ├── logo.png ├── requirements.txt ├── requirements_test.txt ├── .abl ├── .circleci └── config.yml ├── tbump.toml ├── LICENSE.txt ├── setup.py ├── README.md └── .pylintrc /miniboss/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --tb=native -------------------------------------------------------------------------------- /sample-apps/requirements.txt: -------------------------------------------------------------------------------- 1 | miniboss==0.4.2 2 | psycopg2==2.9.3 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | .eggs 3 | **/__pycache__ 4 | build/ 5 | dist/ 6 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afroisalreadyinu/miniboss/HEAD/logo.png -------------------------------------------------------------------------------- /sample-apps/python-todo/requirements.txt: -------------------------------------------------------------------------------- 1 | flask==1.1.2 2 | flask-sqlalchemy==2.4.3 3 | psycopg2==2.8.5 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | attrs==20.3.0 2 | click==8.1.3 3 | docker==5.0.3 4 | furl==2.1.0 5 | python-slugify==6.1.1 6 | requests==2.23.0 7 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | -r requirements.txt 3 | black==22.6.0 4 | isort==5.10.1 5 | mypy==0.942 6 | pdbpp==0.10.3 7 | pylint==2.13.5 8 | pytest==6.2.5 9 | types-python-slugify==5.0.3 10 | -------------------------------------------------------------------------------- /sample-apps/python-todo/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.5 2 | 3 | WORKDIR /opt/python-todo 4 | 5 | COPY . . 6 | 7 | RUN pip install --upgrade pip 8 | RUN pip install -r requirements.txt 9 | 10 | CMD ["python3", "app.py"] 11 | -------------------------------------------------------------------------------- /miniboss/__init__.py: -------------------------------------------------------------------------------- 1 | from .context import Context 2 | from .main import cli 3 | from .services import Service, on_reload_service, on_start_services, on_stop_services 4 | from .types import set_group_name as group_name 5 | 6 | __version__ = "0.4.5" 7 | -------------------------------------------------------------------------------- /.abl: -------------------------------------------------------------------------------- 1 | abl-mode-test-command "pytest %s -x --tb=native" 2 | abl-mode-install-command "pip install -r requirements_test.txt" 3 | abl-mode-test-file-regexp "test_.*.py*" 4 | abl-mode-test-path-module-class-separator "::" 5 | abl-mode-test-path-class-method-separator "::" 6 | abl-mode-use-file-module 'nil -------------------------------------------------------------------------------- /sample-apps/python-todo/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Todos 4 | 5 |

Todos

6 | 11 |
12 | 13 | 14 | 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | python: circleci/python@0.2.1 5 | 6 | jobs: 7 | build-and-test: 8 | executor: python/default 9 | steps: 10 | - checkout 11 | - python/load-cache 12 | - run: 13 | command: pip install --user -r requirements_test.txt 14 | - python/save-cache 15 | - run: 16 | command: python -m black --check --diff . 17 | name: Check formatting 18 | - run: 19 | command: python -m isort --check --diff --profile black . 20 | name: Check import sorting 21 | - run: 22 | command: python -m pylint miniboss 23 | name: Lint 24 | - run: 25 | command: python -m mypy ./miniboss 26 | name: Lint 27 | - run: 28 | command: python -m pytest 29 | name: Test 30 | 31 | workflows: 32 | main: 33 | jobs: 34 | - build-and-test 35 | -------------------------------------------------------------------------------- /miniboss/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | 4 | class MinibossException(Exception): 5 | pass 6 | 7 | 8 | class ServiceLoadError(MinibossException): 9 | pass 10 | 11 | 12 | class ServiceDefinitionError(MinibossException): 13 | pass 14 | 15 | 16 | class ServiceAgentException(MinibossException): 17 | pass 18 | 19 | 20 | class MinibossCLIError(MinibossException): 21 | pass 22 | 23 | 24 | class ContextError(MinibossException): 25 | pass 26 | 27 | 28 | class DockerException(MinibossException): 29 | pass 30 | 31 | 32 | class ContainerStartException(DockerException): 33 | def __init__( 34 | self, logs: str, container_name: str, *args: list[Any], **kwargs: dict[str, Any] 35 | ) -> None: 36 | self.logs = logs 37 | self.container_name = container_name 38 | super().__init__(*args, **kwargs) 39 | 40 | def __str__(self) -> str: 41 | return "Logs: \n" + self.logs 42 | -------------------------------------------------------------------------------- /sample-apps/python-todo/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime 3 | 4 | from flask import Flask, redirect, render_template, request 5 | from flask_sqlalchemy import SQLAlchemy 6 | 7 | app = Flask("todo-app") 8 | 9 | app.config["SQLALCHEMY_DATABASE_URI"] = os.environ["DB_URI"] 10 | db = SQLAlchemy(app) 11 | 12 | 13 | class TodoItem(db.Model): 14 | id = db.Column(db.Integer, primary_key=True) 15 | created_at = db.Column(db.DateTime, default=datetime.utcnow) 16 | what_to_do = db.Column(db.String, nullable=False) 17 | 18 | 19 | @app.route("/") 20 | def index(): 21 | return render_template("index.html", todos=TodoItem.query.all()) 22 | 23 | 24 | @app.route("/add/", methods=["POST"]) 25 | def add_todo(): 26 | new_todo = TodoItem(what_to_do=request.form["what-to-do"]) 27 | db.session.add(new_todo) 28 | db.session.commit() 29 | return redirect("/") 30 | 31 | 32 | if __name__ == "__main__": 33 | db.create_all() 34 | app.run(host="0.0.0.0", port=8080) 35 | -------------------------------------------------------------------------------- /tbump.toml: -------------------------------------------------------------------------------- 1 | github_url = "https://github.com/afroisalreadyinu/miniboss/" 2 | 3 | [version] 4 | current = "0.4.5" 5 | 6 | # Example of a semver regexp. 7 | # Make sure this matches current_version before 8 | # using tbump 9 | regex = ''' 10 | (?P\d+) 11 | \. 12 | (?P\d+) 13 | \. 14 | (?P\d+) 15 | ''' 16 | 17 | [git] 18 | message_template = "Bump to {new_version}" 19 | tag_template = "v{new_version}" 20 | 21 | # For each file to patch, add a [[file]] config 22 | # section containing the path of the file, relative to the 23 | # tbump.toml location. 24 | [[file]] 25 | src = "setup.py" 26 | 27 | [[file]] 28 | src = "miniboss/__init__.py" 29 | search = '__version__ = "{current_version}"' 30 | 31 | # You can specify a list of commands to 32 | # run after the files have been patched 33 | # and before the git commit is made 34 | 35 | # [[before_commit]] 36 | # name = "check changelog" 37 | # cmd = "grep -q {new_version} Changelog.rst" 38 | 39 | # Or run some commands after the git tag and the branch 40 | # have been pushed: 41 | # [[after_push]] 42 | # name = "publish" 43 | # cmd = "./publish.sh" 44 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2020] [Ulas Turkmen] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import setup 4 | 5 | with open("README.md", "r", encoding="utf-8") as f: 6 | long_description = f.read() 7 | 8 | setup( 9 | name="miniboss", 10 | version="0.4.5", 11 | author="Ulas Turkmen", 12 | description="Containerized app testing framework", 13 | long_description=long_description, 14 | long_description_content_type="text/markdown", 15 | install_requires=[ 16 | "click>7", 17 | "docker>4", 18 | "furl>2", 19 | "requests>2", 20 | "attrs>20", 21 | "python-slugify>6.0.0", 22 | ], 23 | python_requires=">3.8.0", 24 | tests_require=["pytest>5.4"], 25 | packages=["miniboss"], 26 | package_data={"miniboss": ["py.typed"]}, 27 | url="https://github.com/afroisalreadyinu/miniboss", 28 | license="MIT", 29 | classifiers=[ 30 | "License :: OSI Approved :: MIT License", 31 | "Development Status :: 3 - Alpha", 32 | "Topic :: Software Development :: Build Tools", 33 | "Topic :: Software Development :: Testing", 34 | "Programming Language :: Python :: 3", 35 | "Intended Audience :: Developers", 36 | ], 37 | ) 38 | -------------------------------------------------------------------------------- /tests/unit/test_types.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import tempfile 3 | import unittest 4 | from pathlib import Path 5 | 6 | import pytest 7 | 8 | from miniboss import types 9 | from miniboss.exceptions import MinibossException 10 | 11 | 12 | class GroupNameTests(unittest.TestCase): 13 | def setUp(self): 14 | types._unset_group_name() 15 | self.workdir = tempfile.mkdtemp() 16 | 17 | def tearDown(self): 18 | shutil.rmtree(self.workdir) 19 | 20 | def test_set_group_name(self): 21 | types.set_group_name("test-group") 22 | assert types.group_name == "test-group" 23 | 24 | def test_error_on_group_name_reset(self): 25 | types.set_group_name("test-group") 26 | with pytest.raises(MinibossException) as context: 27 | types.set_group_name("test-group") 28 | 29 | def test_update_group_name(self): 30 | workdir = Path(self.workdir) / "some weird dir" 31 | types.update_group_name(workdir) 32 | assert types.group_name == "some-weird-dir" 33 | 34 | def test_update_group_name_existing_stays(self): 35 | types.set_group_name("test-group") 36 | workdir = Path(self.workdir) / "some weird dir" 37 | types.update_group_name(workdir) 38 | assert types.group_name == "test-group" 39 | -------------------------------------------------------------------------------- /sample-apps/miniboss-main.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | import logging 3 | 4 | import psycopg2 5 | 6 | import miniboss 7 | 8 | logging.basicConfig(level=logging.INFO, format="[%(name)s] %(message)s") 9 | 10 | miniboss.group_name("readme-demo") 11 | 12 | 13 | class Database(miniboss.Service): 14 | name = "appdb" 15 | image = "postgres:10.6" 16 | env = { 17 | "POSTGRES_PASSWORD": "dbpwd", 18 | "POSTGRES_USER": "dbuser", 19 | "POSTGRES_DB": "appdb", 20 | } 21 | ports = {5432: 5433} 22 | 23 | def ping(self): 24 | try: 25 | connection = psycopg2.connect( 26 | "postgresql://dbuser:dbpwd@localhost:5433/appdb" 27 | ) 28 | cur = connection.cursor() 29 | cur.execute("SELECT 1") 30 | except psycopg2.OperationalError: 31 | return False 32 | else: 33 | return True 34 | 35 | 36 | class Application(miniboss.Service): 37 | name = "python-todo" 38 | image = "python-todo:latest" 39 | env = {"DB_URI": "postgresql://dbuser:dbpwd@appdb:5432/appdb"} 40 | dependencies = ["appdb"] 41 | ports = {8080: 8080} 42 | stop_signal = "SIGINT" 43 | build_from = "python-todo" 44 | 45 | 46 | def print_info(services): 47 | if Application.name in services: 48 | print("TODO app can be accessed at http://localhost:8080") 49 | 50 | 51 | miniboss.on_start_services(print_info) 52 | 53 | if __name__ == "__main__": 54 | miniboss.cli() 55 | -------------------------------------------------------------------------------- /miniboss/running_context.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import threading 4 | from typing import TYPE_CHECKING 5 | 6 | from miniboss.service_agent import Options, ServiceAgent 7 | 8 | if TYPE_CHECKING: 9 | from miniboss.services import Service 10 | 11 | 12 | class RunningContext: 13 | def __init__(self, services_by_name: dict[str, Service], options: Options): 14 | super().__init__() 15 | self.agent_set = { 16 | service: ServiceAgent(service, options, self) 17 | for name, service in services_by_name.items() 18 | } 19 | self.failed_services: list[Service] = [] 20 | self.processed_services: list[Service] = [] 21 | self.service_pop_lock = threading.Lock() 22 | 23 | @property 24 | def done(self) -> bool: 25 | return self.agent_set == {} 26 | 27 | @property 28 | def context_failed(self) -> bool: 29 | return bool(self.failed_services) 30 | 31 | @property 32 | def ready_to_start(self) -> list[ServiceAgent]: 33 | return [x for x in self.agent_set.values() if x.can_start] 34 | 35 | @property 36 | def ready_to_stop(self) -> list[ServiceAgent]: 37 | return [x for x in self.agent_set.values() if x.can_stop] 38 | 39 | def service_failed(self, failed_service: Service) -> None: 40 | with self.service_pop_lock: 41 | self.agent_set.pop(failed_service) 42 | self.failed_services.append(failed_service) 43 | services_left = list(self.agent_set.keys()) 44 | for service in services_left: 45 | if failed_service in service.dependencies: 46 | self.service_failed(service) 47 | 48 | def service_started(self, started_service: Service) -> None: 49 | with self.service_pop_lock: 50 | self.agent_set.pop(started_service) 51 | self.processed_services.append(started_service) 52 | for agent in self.agent_set.values(): 53 | agent.process_service_started(started_service) 54 | 55 | def service_stopped(self, stopped_service: Service) -> None: 56 | with self.service_pop_lock: 57 | self.agent_set.pop(stopped_service) 58 | self.processed_services.append(stopped_service) 59 | for agent in self.agent_set.values(): 60 | agent.process_service_stopped(stopped_service) 61 | -------------------------------------------------------------------------------- /miniboss/context.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import pathlib 4 | from typing import Any 5 | 6 | from miniboss.exceptions import ContextError 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class _Context(dict[str, Any]): 12 | filename = ".miniboss-context" 13 | 14 | def save_to(self, directory: str) -> None: 15 | path = pathlib.Path(directory) / self.filename 16 | with open(path, "w", encoding="utf-8") as context_file: 17 | context_file.write(json.dumps(self)) 18 | 19 | def load_from(self, directory: str) -> None: 20 | path = pathlib.Path(directory) / self.filename 21 | try: 22 | with open(path, "r", encoding="utf-8") as context_file: 23 | new_data = json.load(context_file) 24 | self.update(**new_data) 25 | except FileNotFoundError: 26 | logger.info("No miniboss context file in %s", directory) 27 | 28 | def remove_file(self, directory: str) -> None: 29 | path = pathlib.Path(directory) / self.filename 30 | try: 31 | path.unlink() 32 | except FileNotFoundError: 33 | logger.info("No miniboss context file in %s", directory) 34 | 35 | def extrapolate(self, env_value: Any) -> Any: 36 | if not hasattr(env_value, "format"): 37 | return env_value 38 | try: 39 | return env_value.format(**self) 40 | except KeyError: 41 | keys = ",".join(self.keys()) 42 | exc = ContextError( 43 | f"Could not extrapolate string '{env_value}', existing keys: {keys}" 44 | ) 45 | raise exc from None 46 | except ValueError: 47 | # This happens when there is a type mismatch 48 | exc = ContextError( 49 | f"Could not extrapolate string '{env_value}' due to type mismatch" 50 | ) 51 | raise exc from None 52 | except IndexError: 53 | msg = "Only keyword argument extrapolation allowed, violating string: '{env_value}'" 54 | raise ContextError(msg) from None 55 | 56 | def extrapolate_values(self, a_dict: dict[str, Any]) -> dict[str, Any]: 57 | return {key: self.extrapolate(value) for key, value in a_dict.items()} 58 | 59 | def _reset(self) -> None: 60 | # Used only for testing 61 | for key in list(self.keys()): 62 | self.pop(key) 63 | 64 | 65 | Context = _Context() 66 | -------------------------------------------------------------------------------- /miniboss/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import click 4 | 5 | from miniboss import services 6 | from miniboss.exceptions import MinibossCLIError 7 | 8 | 9 | @click.group() 10 | def cli(): 11 | pass 12 | 13 | 14 | def get_main_directory() -> str: 15 | """Return the path to the directory where the main script is located. If the cli 16 | function is being called from a Python shell, this function will raise an 17 | exception.""" 18 | # pylint: disable=import-outside-toplevel 19 | import __main__ 20 | 21 | if not hasattr(__main__, "__file__"): 22 | raise MinibossCLIError("Please call miniboss.cli from a Python script") 23 | return os.path.dirname(os.path.abspath(__main__.__file__)) 24 | 25 | 26 | @cli.command() 27 | @click.option("--exclude", help="Names of services to exclude (comma-separated)") 28 | @click.option( 29 | "--network-name", help="Network name (generated from group name if not specified)" 30 | ) 31 | @click.option( 32 | "--timeout", type=int, default=300, help="Timeout for starting a service (seconds)" 33 | ) 34 | def start(exclude: str, network_name: str, timeout: int): 35 | excluded = exclude.split(",") if exclude else [] 36 | services.start_services(get_main_directory(), excluded, network_name, timeout) 37 | 38 | 39 | @cli.command() 40 | @click.option("--exclude", help="Names of services to exclude (comma-separated)") 41 | @click.option( 42 | "--network-name", help="Network name (generated from group name if not specified)" 43 | ) 44 | @click.option( 45 | "--remove", is_flag=True, default=False, help="Remove container images and network" 46 | ) 47 | @click.option( 48 | "--timeout", type=int, default=50, help="Timeout for stopping a service (seconds)" 49 | ) 50 | def stop(exclude: str, network_name, remove, timeout): 51 | excluded = exclude.split(",") if exclude else [] 52 | services.stop_services( 53 | get_main_directory(), excluded, network_name, remove, timeout 54 | ) 55 | 56 | 57 | @cli.command() 58 | @click.option( 59 | "--network-name", help="Network name (generated from group name if not specified)" 60 | ) 61 | @click.option( 62 | "--timeout", type=int, default=50, help="Timeout for stopping a service (seconds)" 63 | ) 64 | @click.option("--remove", is_flag=True, default=False, help="Remove stopped container") 65 | @click.argument("service") 66 | def reload(service: str, network_name: str, timeout: int, remove: bool): 67 | services.reload_service( 68 | get_main_directory(), service, network_name, remove, timeout 69 | ) 70 | -------------------------------------------------------------------------------- /tests/unit/test_context.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import tempfile 4 | import unittest 5 | 6 | import pytest 7 | 8 | from miniboss.context import ContextError, _Context 9 | 10 | 11 | class ContextTests(unittest.TestCase): 12 | def test_extrapolate(self): 13 | context = _Context(blah=123, yada="hello") 14 | output = context.extrapolate("Say {yada} to {blah}") 15 | assert output == "Say hello to 123" 16 | 17 | def test_extrapolate_nonstring(self): 18 | context = _Context(blah=123, yada="hello") 19 | assert 20 == context.extrapolate(20) 20 | 21 | def test_extrapolate_key_missing(self): 22 | context = _Context(blah=123, yada="hello") 23 | with pytest.raises(ContextError): 24 | context.extrapolate("Say {hello} to {blah}") 25 | 26 | def test_extrapolate_index_error(self): 27 | context = _Context(blah=123, yada="hello") 28 | with pytest.raises(ContextError): 29 | context.extrapolate("Say {} to {blah}") 30 | 31 | def test_extrapolate_type_mismatch(self): 32 | context = _Context(blah=123, yada="hello") 33 | with pytest.raises(ContextError): 34 | context.extrapolate("Say {blah:s} to {yada}") 35 | 36 | def test_extrapolate_values(self): 37 | context = _Context(blah=123, yada="hello") 38 | output = context.extrapolate_values( 39 | {"key1": "This is {blah}", "key2": "And this is {yada}", "key3": 456} 40 | ) 41 | assert output == { 42 | "key1": "This is 123", 43 | "key2": "And this is hello", 44 | "key3": 456, 45 | } 46 | 47 | def test_save_to_load_from(self): 48 | directory = tempfile.mkdtemp() 49 | context = _Context(blah=123, yada="hello") 50 | context.save_to(directory) 51 | path = os.path.join(directory, ".miniboss-context") 52 | assert os.path.exists(path) 53 | with open(path, "r") as in_file: 54 | data = json.load(in_file) 55 | assert data == {"blah": 123, "yada": "hello"} 56 | new_context = _Context() 57 | new_context.load_from(directory) 58 | assert new_context["blah"] == 123 59 | assert new_context["yada"] == "hello" 60 | 61 | def test_load_from_missing(self): 62 | context = _Context() 63 | context.load_from("/not/existing/directory/blahakshdakusdhau") 64 | 65 | def test_remove_file(self): 66 | directory = tempfile.mkdtemp() 67 | context = _Context(blah=123, yada="hello") 68 | context.save_to(directory) 69 | path = os.path.join(directory, ".miniboss-context") 70 | assert os.path.exists(path) 71 | context.remove_file(directory) 72 | assert not os.path.exists(path) 73 | 74 | def test_remove_file_missing(self): 75 | context = _Context() 76 | context.remove_file("/not/existing/directory/blahakshdakusdhau") 77 | -------------------------------------------------------------------------------- /miniboss/types.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Iterable, Union 3 | 4 | import attr 5 | from attr.validators import deep_iterable, instance_of 6 | from slugify import slugify 7 | 8 | from miniboss.exceptions import MinibossException 9 | 10 | 11 | @attr.s(kw_only=True) 12 | class Network: 13 | name: str = attr.ib(validator=instance_of(str)) 14 | id: str = attr.ib(validator=instance_of(str)) 15 | 16 | 17 | @attr.s(kw_only=True) 18 | class Options: 19 | network: Network = attr.ib(validator=instance_of(Network)) 20 | timeout: Union[float, int] = attr.ib(validator=instance_of((float, int))) 21 | remove: bool = attr.ib(validator=instance_of(bool)) 22 | run_dir: str = attr.ib(validator=instance_of(str)) 23 | build: Iterable[str] = attr.ib( 24 | validator=deep_iterable(member_validator=instance_of(str)) 25 | ) 26 | 27 | 28 | class AgentStatus: 29 | NULL = "null" 30 | IN_PROGRESS = "in-progress" 31 | STARTED = "started" 32 | FAILED = "failed" 33 | STOPPED = "stopped" 34 | 35 | 36 | class RunCondition: 37 | # Actions 38 | CREATE = "create" 39 | START = "start" 40 | PRE_START = "pre-start" 41 | POST_START = "post-start" 42 | PING = "ping" 43 | # States 44 | NULL = "null" 45 | BUILD_IMAGE = "build-image" 46 | STARTED = "started" 47 | RUNNING = "running" 48 | FAILED = "failed" 49 | 50 | def __init__(self) -> None: 51 | self.actions: list[str] = [] 52 | self.state = self.NULL 53 | 54 | def already_running(self) -> None: 55 | self.state = self.RUNNING 56 | 57 | def pinged(self) -> None: 58 | self.actions.append(self.PING) 59 | self.state = self.RUNNING 60 | 61 | def pre_started(self) -> None: 62 | self.actions.append(self.PRE_START) 63 | 64 | def post_started(self) -> None: 65 | self.actions.append(self.POST_START) 66 | 67 | def build_image(self) -> None: 68 | self.actions.append(self.BUILD_IMAGE) 69 | 70 | def started(self) -> None: 71 | self.state = self.STARTED 72 | self.actions.append(self.START) 73 | 74 | def fail(self) -> None: 75 | self.state = self.FAILED 76 | 77 | 78 | class Actions: 79 | START = "start" 80 | STOP = "stop" 81 | 82 | 83 | group_name: Union[str, None] = None 84 | 85 | 86 | def update_group_name(maindir: str) -> str: 87 | global group_name 88 | if group_name is None: 89 | group_name = slugify(Path(maindir).name) 90 | return group_name 91 | 92 | 93 | def set_group_name(name: str) -> None: 94 | global group_name 95 | if group_name is not None: 96 | raise MinibossException("Group name has already been set, it cannot be changed") 97 | group_name = name 98 | 99 | 100 | def _unset_group_name() -> None: 101 | global group_name 102 | group_name = None 103 | -------------------------------------------------------------------------------- /tests/unit/test_main.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest import TestCase, mock 3 | 4 | from click.testing import CliRunner 5 | 6 | from miniboss import main, types 7 | 8 | 9 | class MainTests(TestCase): 10 | @mock.patch("miniboss.main.services") 11 | def test_start(self, mock_services): 12 | runner = CliRunner() 13 | result = runner.invoke(main.start) 14 | # There is something weird happening with __main__ depending on how 15 | # tests are executed, let's just skip that 16 | assert mock_services.start_services.call_count == 1 17 | args = mock_services.start_services.mock_calls[0][1] 18 | assert args[1:] == ([], None, 300) 19 | 20 | @mock.patch("miniboss.main.services") 21 | def test_start_args(self, mock_services): 22 | runner = CliRunner() 23 | result = runner.invoke( 24 | main.start, 25 | ["--network-name", "yada", "--timeout", "20", "--exclude", "testy"], 26 | ) 27 | # There is something weird happening with __main__ depending on how 28 | # tests are executed, let's just skip that 29 | assert mock_services.start_services.call_count == 1 30 | args = mock_services.start_services.mock_calls[0][1] 31 | assert args[1:] == (["testy"], "yada", 20) 32 | 33 | @mock.patch("miniboss.main.services") 34 | def test_stop(self, mock_services): 35 | runner = CliRunner() 36 | result = runner.invoke(main.stop) 37 | assert mock_services.stop_services.call_count == 1 38 | args = mock_services.stop_services.mock_calls[0][1] 39 | assert args[1:] == ([], None, False, 50) 40 | 41 | @mock.patch("miniboss.main.services") 42 | def test_stop_args(self, mock_services): 43 | runner = CliRunner() 44 | result = runner.invoke( 45 | main.stop, 46 | [ 47 | "--remove", 48 | "--timeout", 49 | "10", 50 | "--network-name", 51 | "yada", 52 | "--exclude", 53 | "testy", 54 | ], 55 | ) 56 | assert mock_services.stop_services.call_count == 1 57 | args = mock_services.stop_services.mock_calls[0][1] 58 | assert args[1:] == (["testy"], "yada", True, 10) 59 | 60 | @mock.patch("miniboss.main.services") 61 | def test_reload(self, mock_services): 62 | runner = CliRunner() 63 | result = runner.invoke(main.reload, ["testy"]) 64 | assert mock_services.reload_service.call_count == 1 65 | args = mock_services.reload_service.mock_calls[0][1] 66 | assert args[1:] == ("testy", None, False, 50) 67 | 68 | @mock.patch("miniboss.main.services") 69 | def test_reload_args(self, mock_services): 70 | runner = CliRunner() 71 | result = runner.invoke( 72 | main.reload, 73 | ["testy", "--remove", "--timeout", "10", "--network-name", "yada"], 74 | ) 75 | assert mock_services.reload_service.call_count == 1 76 | args = mock_services.reload_service.mock_calls[0][1] 77 | assert args[1:] == ("testy", "yada", True, 10) 78 | -------------------------------------------------------------------------------- /tests/unit/common.py: -------------------------------------------------------------------------------- 1 | import time 2 | import uuid 3 | from types import SimpleNamespace as Bunch 4 | 5 | from miniboss.types import Network, Options 6 | 7 | DEFAULT_OPTIONS = Options( 8 | network=Network(name="the-network", id="the-network-id"), 9 | timeout=1, 10 | remove=False, 11 | run_dir="/etc", 12 | build=[], 13 | ) 14 | 15 | 16 | class FakeRunningContext: 17 | def __init__(self): 18 | self.started_services = [] 19 | self.stopped_services = [] 20 | self.failed_services = [] 21 | 22 | def service_started(self, service): 23 | self.started_services.append(service) 24 | 25 | def service_stopped(self, service): 26 | self.stopped_services.append(service) 27 | 28 | def service_failed(self, failed_service): 29 | self.failed_services.append(failed_service) 30 | 31 | 32 | class FakeService: 33 | image = "not/used" 34 | _dependants = [] 35 | ports = {} 36 | env = {} 37 | always_start_new = False 38 | build_from = None 39 | dockerfile = "Dockerfile" 40 | 41 | def __init__( 42 | self, 43 | name="service1", 44 | dependencies=None, 45 | fail_ping=False, 46 | exception_at_init=None, 47 | ): 48 | self.name = name 49 | self.dependencies = dependencies or [] 50 | self.fail_ping = fail_ping 51 | self.exception_at_init = exception_at_init 52 | self.ping_count = 0 53 | self.init_called = False 54 | self.pre_start_called = False 55 | 56 | def ping(self): 57 | self.ping_count += 1 58 | return not self.fail_ping 59 | 60 | def pre_start(self): 61 | self.pre_start_called = True 62 | 63 | def post_start(self): 64 | self.init_called = True 65 | if self.exception_at_init: 66 | raise self.exception_at_init() 67 | return True 68 | 69 | def __hash__(self): 70 | return hash(self.name) 71 | 72 | def __eq__(self, other): 73 | return self.__class__ == other.__class__ and self.name == other.name 74 | 75 | 76 | class FakeContainer(Bunch): 77 | def __init__(self, **kwargs): 78 | self.stopped = False 79 | self.removed_at = None 80 | self.timeout = None 81 | super().__init__(**kwargs) 82 | 83 | def stop(self, timeout): 84 | self.stopped = True 85 | self.removed_at = None 86 | self.timeout = timeout 87 | 88 | def remove(self): 89 | time.sleep(0.1) 90 | self.removed_at = time.time() 91 | 92 | 93 | class FakeDocker: 94 | Instance = None 95 | 96 | @classmethod 97 | def get_client(cls): 98 | return cls.Instance 99 | 100 | def __init__(self, network_name_id_mapping=None): 101 | self._networks_created = [] 102 | self._networks_removed = [] 103 | self._services_started = [] 104 | self._existing_queried = [] 105 | self._containers_ran = [] 106 | self._images_built = [] 107 | self._existing_containers = [] 108 | self.network_name_id_mapping = network_name_id_mapping or {} 109 | 110 | def create_network(self, network_name): 111 | self._networks_created.append(network_name) 112 | return Bunch(id=self.network_name_id_mapping[network_name]) 113 | 114 | def remove_network(self, network_name): 115 | self._networks_removed.append(network_name) 116 | 117 | def existing_on_network(self, name, network): 118 | self._existing_queried.append((name, network)) 119 | for container in self._existing_containers: 120 | if ( 121 | container.name.startswith(name) 122 | and self.network_name_id_mapping[container.network] == network.id 123 | ): 124 | return [container] 125 | return [] 126 | 127 | def run_service_on_network(self, name_prefix, service, network): 128 | self._services_started.append((name_prefix, service, network)) 129 | 130 | def run_container(self, container_id): 131 | self._containers_ran.append(container_id) 132 | 133 | def build_image(self, build_dir, dockerfile, image_tag): 134 | self._images_built.append((build_dir, dockerfile, image_tag)) 135 | -------------------------------------------------------------------------------- /tests/unit/test_running_context.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from types import SimpleNamespace as Bunch 3 | from unittest.mock import patch 4 | 5 | from common import DEFAULT_OPTIONS, FakeService 6 | 7 | from miniboss.running_context import RunningContext 8 | from miniboss.service_agent import Options 9 | from miniboss.services import connect_services 10 | 11 | 12 | class RunningContextTests(unittest.TestCase): 13 | def test_service_started(self): 14 | services = connect_services( 15 | [ 16 | FakeService(name="service1", dependencies=[]), 17 | FakeService(name="service2", dependencies=["service1"]), 18 | ] 19 | ) 20 | context = RunningContext(services, DEFAULT_OPTIONS) 21 | assert len(context.agent_set) == 2 22 | context.service_started(services["service1"]) 23 | assert len(context.processed_services) == 1 24 | assert context.processed_services[0].name == "service1" 25 | assert len(context.agent_set) == 1 26 | assert services["service2"] in context.agent_set 27 | assert context.agent_set[services["service2"]].can_start 28 | 29 | def test_ready_to_start_and_stop(self): 30 | services = connect_services( 31 | [ 32 | FakeService(name="service1", dependencies=[]), 33 | FakeService(name="service2", dependencies=["service1"]), 34 | ] 35 | ) 36 | context = RunningContext(services, DEFAULT_OPTIONS) 37 | assert len(context.ready_to_start) == 1 38 | assert context.ready_to_start[0].service == services["service1"] 39 | assert len(context.ready_to_stop) == 1 40 | assert context.ready_to_stop[0].service == services["service2"] 41 | 42 | def test_service_failed(self): 43 | service = FakeService(name="service1", dependencies=[]) 44 | context = RunningContext({"service": service}, DEFAULT_OPTIONS) 45 | context.service_failed(service) 46 | assert len(context.failed_services) == 1 47 | assert len(context.agent_set) == 0 48 | assert len(context.processed_services) == 0 49 | 50 | def test_service_stopped(self): 51 | services = connect_services( 52 | [ 53 | FakeService(name="service1", dependencies=[]), 54 | FakeService(name="service2", dependencies=["service1"]), 55 | ] 56 | ) 57 | context = RunningContext(services, DEFAULT_OPTIONS) 58 | context.service_stopped(services["service2"]) 59 | assert len(context.agent_set) == 1 60 | assert len(context.processed_services) == 1 61 | assert context.processed_services[0] is services["service2"] 62 | assert services["service1"] in context.agent_set 63 | assert context.agent_set[services["service1"]].can_stop 64 | 65 | def test_done_on_started(self): 66 | services = connect_services( 67 | [ 68 | FakeService(name="service1", dependencies=[]), 69 | FakeService(name="service2", dependencies=[]), 70 | ] 71 | ) 72 | context = RunningContext(services, DEFAULT_OPTIONS) 73 | assert not context.done 74 | context.service_started(services["service1"]) 75 | assert not context.done 76 | context.service_started(services["service2"]) 77 | assert context.done 78 | assert len(context.agent_set) == 0 79 | 80 | def test_done_on_fail(self): 81 | services = connect_services( 82 | [ 83 | FakeService(name="service1", dependencies=[]), 84 | FakeService(name="service2", dependencies=[]), 85 | ] 86 | ) 87 | context = RunningContext(services, DEFAULT_OPTIONS) 88 | assert not context.done 89 | context.service_started(services["service1"]) 90 | assert not context.done 91 | context.service_failed(services["service2"]) 92 | assert context.done 93 | 94 | def test_fail_dependencies(self): 95 | """If a service fails to start, all the other services that depend on it are 96 | also registered as failed""" 97 | services = connect_services( 98 | [ 99 | FakeService(name="service1", dependencies=[]), 100 | FakeService(name="service2", dependencies=["service1"]), 101 | ] 102 | ) 103 | context = RunningContext(services, DEFAULT_OPTIONS) 104 | context.service_failed(services["service1"]) 105 | assert len(context.failed_services) == 2 106 | 107 | @patch("miniboss.running_context.threading") 108 | def test_service_started_lock_call(self, mock_threading): 109 | services = connect_services( 110 | [ 111 | FakeService(name="service1", dependencies=[]), 112 | FakeService(name="service2", dependencies=["service1"]), 113 | ] 114 | ) 115 | context = RunningContext(services, DEFAULT_OPTIONS) 116 | context.service_started(services["service1"]) 117 | mock_lock = mock_threading.Lock.return_value 118 | assert mock_lock.__enter__.call_count == 1 119 | 120 | @patch("miniboss.running_context.threading") 121 | def test_service_failed_lock_call(self, mock_threading): 122 | services = connect_services( 123 | [ 124 | FakeService(name="service1", dependencies=[]), 125 | FakeService(name="service2", dependencies=["service1"]), 126 | ] 127 | ) 128 | context = RunningContext(services, DEFAULT_OPTIONS) 129 | context.service_failed(services["service1"]) 130 | mock_lock = mock_threading.Lock.return_value 131 | # This has to be 2 because service1 has a dependency, and it has to be 132 | # locked as well 133 | assert mock_lock.__enter__.call_count == 2 134 | -------------------------------------------------------------------------------- /miniboss/docker_client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import random 5 | import time 6 | from typing import TYPE_CHECKING, Optional 7 | 8 | import docker # type: ignore 9 | import docker.errors # type: ignore 10 | 11 | from miniboss.exceptions import ContainerStartException, DockerException 12 | from miniboss.types import Network 13 | 14 | if TYPE_CHECKING: 15 | from miniboss.services import Service 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | DIGITS = "0123456789" 20 | 21 | _the_docker: Optional[docker.DockerClient] = None 22 | 23 | 24 | class DockerClient: 25 | def __init__(self, lib_client: docker.DockerClient): 26 | self.lib_client = lib_client 27 | 28 | @classmethod 29 | def get_client(cls) -> DockerClient: 30 | global _the_docker 31 | if _the_docker is None: 32 | _the_docker = cls(docker.from_env()) 33 | return _the_docker 34 | 35 | def create_network(self, network_name: str) -> docker.models.networks.Network: 36 | existing = self.lib_client.networks.list(names=[network_name]) 37 | if existing: 38 | network = existing[0] 39 | else: 40 | network = self.lib_client.networks.create(network_name, driver="bridge") 41 | logger.info("Created network %s", network_name) 42 | return network 43 | 44 | def remove_network(self, network_name: str) -> None: 45 | networks = self.lib_client.networks.list(names=[network_name]) 46 | if networks: 47 | networks[0].remove() 48 | logger.info("Removed network %s", network_name) 49 | 50 | def existing_on_network( 51 | self, name: str, network: Network 52 | ) -> list[docker.models.containers.Container]: 53 | return self.lib_client.containers.list( 54 | all=True, filters={"network": network.id, "name": name} 55 | ) 56 | 57 | def build_image(self, build_dir, dockerfile, image_tag): 58 | try: 59 | self.lib_client.images.build( 60 | tag=image_tag, path=build_dir, dockerfile=dockerfile 61 | ) 62 | except docker.errors.BuildError as build_error: 63 | raise DockerException(f"Error building image: {build_error.msg}") from None 64 | except docker.errors.APIError as api_error: 65 | raise DockerException( 66 | f"Error building image: {api_error.explanation}" 67 | ) from None 68 | 69 | def run_container(self, container_id: str): 70 | # The container should be already created but not in state running or starting 71 | try: 72 | self.lib_client.api.start(container_id) 73 | except docker.errors.APIError as api_error: 74 | # This might be e.g. due to cgroups errors 75 | msg = f"Error starting container {container_id}: {api_error.explanation}" 76 | raise DockerException(msg) from None 77 | # Let's wait a little because the status of the container is 78 | # not set right away 79 | time.sleep(1) 80 | try: 81 | container = self.lib_client.containers.get(container_id) 82 | except docker.errors.NotFound: 83 | msg = f"Something went terribly wrong: Could not find container {container_id}" 84 | raise DockerException(msg) from None 85 | if container.status != "running": 86 | logs = self.lib_client.api.logs(container.id).decode("utf-8") 87 | raise ContainerStartException(logs, container.name) 88 | return container 89 | 90 | def check_image(self, tag): 91 | try: 92 | self.lib_client.images.get(tag) 93 | except docker.errors.ImageNotFound: 94 | pass 95 | else: 96 | return 97 | logger.info("Image %s does not exist, will pull it", tag) 98 | try: 99 | self.lib_client.images.pull(tag) 100 | except docker.errors.APIError as api_error: 101 | msg = ( 102 | f"Could not pull image {tag} due to API error: {api_error.explanation}" 103 | ) 104 | raise DockerException(msg) from None 105 | 106 | def run_service_on_network( 107 | self, name_prefix, service: Service, network: Network 108 | ) -> str: 109 | random_suffix = "".join(random.sample(DIGITS, 4)) 110 | container_name = f"{name_prefix}-{random_suffix}" 111 | networking_config = self.lib_client.api.create_networking_config( 112 | { 113 | network.name: self.lib_client.api.create_endpoint_config( 114 | aliases=[service.name] 115 | ), 116 | } 117 | ) 118 | host_config = self.lib_client.api.create_host_config( 119 | port_bindings=service.ports, binds=service.volumes 120 | ) 121 | self.check_image(service.image) 122 | kw_arguments = { 123 | "detach": True, 124 | "name": container_name, 125 | "ports": list(service.ports.keys()), 126 | "environment": service.env, 127 | "host_config": host_config, 128 | "networking_config": networking_config, 129 | "volumes": service.volume_def_to_binds(), 130 | "stop_signal": service.stop_signal, 131 | } 132 | if service.entrypoint: 133 | kw_arguments["entrypoint"] = service.entrypoint 134 | if service.cmd: 135 | kw_arguments["command"] = service.cmd 136 | if service.user: 137 | kw_arguments["user"] = service.user 138 | try: 139 | container = self.lib_client.api.create_container( 140 | service.image, **kw_arguments 141 | ) 142 | except docker.errors.ImageNotFound: 143 | msg = f"Image {service.image:s} could not be found; please make sure it exists" 144 | raise DockerException(msg) from None 145 | container = self.run_container(container.get("Id")) 146 | logger.info( 147 | "Started container id %s for service %s", container.id, service.name 148 | ) 149 | return container_name 150 | -------------------------------------------------------------------------------- /miniboss/service_agent.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import os 5 | import threading 6 | import time 7 | from datetime import datetime 8 | from typing import TYPE_CHECKING 9 | 10 | from miniboss import types 11 | from miniboss.context import Context 12 | from miniboss.docker_client import DockerClient 13 | from miniboss.exceptions import ServiceAgentException 14 | from miniboss.types import Actions, AgentStatus, Options, RunCondition 15 | 16 | if TYPE_CHECKING: 17 | from miniboss.running_context import RunningContext 18 | from miniboss.services import Service 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | def container_env(container): 24 | env = container.attrs["Config"]["Env"] 25 | retval = {} 26 | for env_line in env: 27 | key, value = env_line.split("=", 1) 28 | retval[key] = value 29 | return retval 30 | 31 | 32 | def differing_keys(specified, existing): 33 | """Diff the two environment dictionaries, the first one being the one specified 34 | in the service def, and the other of an existing container image. Only a one 35 | way diff; we ignore keys in `existing` that are not in `specified`. We are 36 | also converting the keys from specified to string because the values from 37 | existing are always strings anyway.""" 38 | return [key for key, value in specified.items() if str(value) != existing.get(key)] 39 | 40 | 41 | class ServiceAgent(threading.Thread): 42 | def __init__(self, service: Service, options: Options, context: RunningContext): 43 | super().__init__() 44 | self.service = service 45 | self.options = options 46 | self.context = context 47 | self.open_dependencies = service.dependencies[:] 48 | self.open_dependants = service._dependants[:] 49 | self.run_condition = RunCondition() 50 | self.status = AgentStatus.NULL 51 | self._action = None 52 | 53 | def __repr__(self): 54 | return f"" 55 | 56 | @property 57 | def action(self): 58 | return self._action 59 | 60 | @action.setter 61 | def action(self, aktion): 62 | if not aktion in [Actions.START, Actions.STOP]: 63 | raise ServiceAgentException("Agent action must be one of start or stop") 64 | self._action = aktion 65 | 66 | @property 67 | def can_start(self): 68 | return self.open_dependencies == [] and self.status == AgentStatus.NULL 69 | 70 | @property 71 | def can_stop(self): 72 | return self.open_dependants == [] and self.status == AgentStatus.NULL 73 | 74 | @property 75 | def container_name_prefix(self): 76 | return f"{self.service.name:s}-{types.group_name:s}" 77 | 78 | def process_service_started(self, service): 79 | if service in self.open_dependencies: 80 | self.open_dependencies.remove(service) 81 | 82 | def process_service_stopped(self, service): 83 | if service in self.open_dependants: 84 | self.open_dependants.remove(service) 85 | 86 | def build_image(self): 87 | client = DockerClient.get_client() 88 | time_tag = datetime.now().strftime("%Y-%m-%d-%H%M") 89 | image_tag = f"{self.service.name:s}-{time_tag:s}" 90 | build_dir = os.path.join(self.options.run_dir, self.service.build_from) 91 | logger.info( 92 | "Building image with tag %s for service %s from directory %s", 93 | image_tag, 94 | self.service.name, 95 | build_dir, 96 | ) 97 | client.build_image(build_dir, self.service.dockerfile, image_tag) 98 | self.run_condition.build_image() 99 | return image_tag 100 | 101 | def _start_existing(self, existings): 102 | # pylint: disable=fixme 103 | # TODO fix this; it should be able to deal with multiple existing 104 | # containers 105 | existing = existings[0] 106 | if existing.status == "running": 107 | logger.info( 108 | "Found running container for %s, not starting a new one", 109 | self.service.name, 110 | ) 111 | self.run_condition.already_running() 112 | return 113 | client = DockerClient.get_client() 114 | if existing.status == "exited": 115 | existing_env = container_env(existing) 116 | diff_keys = differing_keys(self.service.env, existing_env) 117 | if diff_keys: 118 | logger.info( 119 | "Differing env key(s) in existing container for service %s: %s", 120 | self.service.name, 121 | ",".join(diff_keys), 122 | ) 123 | start_new = ( 124 | self.service.always_start_new 125 | or self.service.image not in existing.image.tags 126 | or bool(diff_keys) 127 | ) 128 | if not start_new: 129 | logger.info( 130 | "There is an existing container for %s, not creating a new one", 131 | self.service.name, 132 | ) 133 | self.run_condition.started() 134 | client.run_container(existing.id) 135 | if not self.ping(): 136 | self._fail() 137 | 138 | def run_image(self): # returns RunCondition 139 | # pylint: disable=import-outside-toplevel, cyclic-import 140 | from miniboss.services import Service 141 | 142 | client = DockerClient.get_client() 143 | self.service.env = Context.extrapolate_values(self.service.env) 144 | # If there are any running with the name prefix, connected to the same 145 | # network, skip creating 146 | existings = client.existing_on_network( 147 | self.container_name_prefix, self.options.network 148 | ) 149 | if existings: 150 | self._start_existing(existings) 151 | if self.run_condition.state in [RunCondition.STARTED, RunCondition.RUNNING]: 152 | return 153 | logger.info("Creating new container for service %s", self.service.name) 154 | self.service.pre_start() 155 | if self.service.pre_start.__func__ is not Service.pre_start: 156 | logger.info("pre_start for service %s ran", self.service.name) 157 | self.run_condition.pre_started() 158 | client.run_service_on_network( 159 | self.container_name_prefix, self.service, self.options.network 160 | ) 161 | 162 | self.run_condition.started() 163 | if not self.ping(): 164 | self._fail() 165 | return 166 | self.service.post_start() 167 | self.run_condition.post_started() 168 | if self.service.post_start.__func__ is not Service.post_start: 169 | logger.info("post_start for service %s ran", self.service.name) 170 | 171 | def ping(self): 172 | start = time.monotonic() 173 | while time.monotonic() - start < self.options.timeout: 174 | if self.service.ping(): 175 | logger.info("Service %s pinged successfully", self.service.name) 176 | self.run_condition.pinged() 177 | return True 178 | time.sleep(0.1) 179 | logger.error("Could not ping service with timeout of %d", self.options.timeout) 180 | return False 181 | 182 | def start_service(self): 183 | self.action = Actions.START 184 | self.start() 185 | 186 | def stop_service(self): 187 | self.action = Actions.STOP 188 | self.start() 189 | 190 | def run(self): 191 | if self.action is None: 192 | self.status = AgentStatus.FAILED 193 | self.context.service_failed(self.service) 194 | raise ServiceAgentException("Agent cannot be started without an action set") 195 | self.status = AgentStatus.IN_PROGRESS 196 | if self.action == Actions.START: 197 | self.start_container() 198 | elif self.action == Actions.STOP: 199 | self.stop_container() 200 | 201 | def _fail(self): 202 | self.status = AgentStatus.FAILED 203 | self.run_condition.fail() 204 | self.context.service_failed(self.service) 205 | if RunCondition.START in self.run_condition.actions: 206 | self._stop_container(remove=True) 207 | 208 | def start_container(self): 209 | if self.service.name in self.options.build or ( 210 | self.service.build_from and self.service.image.endswith(":latest") 211 | ): 212 | tag = self.build_image() 213 | self.service.image = tag 214 | try: 215 | self.run_image() 216 | except Exception: # pylint: disable=broad-except 217 | logger.exception("Error starting service") 218 | self._fail() 219 | if self.run_condition.state == RunCondition.RUNNING: 220 | logger.info("Service %s started successfully", self.service.name) 221 | self.status = AgentStatus.STARTED 222 | self.context.service_started(self.service) 223 | 224 | def _stop_container(self, remove): 225 | client = DockerClient.get_client() 226 | existings = client.existing_on_network( 227 | self.container_name_prefix, self.options.network 228 | ) 229 | if not existings: 230 | logger.info("No containers to stop for %s", self.service.name) 231 | for existing in existings: 232 | if existing.status == "running": 233 | existing.stop(timeout=self.options.timeout) 234 | logger.info("Stopped container %s", existing.name) 235 | if remove: 236 | existing.remove() 237 | logger.info("Removed container %s", existing.name) 238 | 239 | def stop_container(self): 240 | self._stop_container(remove=self.options.remove) 241 | self.status = AgentStatus.STOPPED 242 | self.context.service_stopped(self.service) 243 | -------------------------------------------------------------------------------- /tests/integration/test_docker_client.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import tempfile 4 | import unittest 5 | import uuid 6 | 7 | import docker 8 | import docker.errors 9 | import pytest 10 | import requests 11 | 12 | import miniboss 13 | from miniboss import exceptions 14 | from miniboss.docker_client import DockerClient 15 | from miniboss.types import Network 16 | 17 | _lib_client = None 18 | 19 | 20 | def get_lib_client(): 21 | global _lib_client 22 | if _lib_client is None: 23 | _lib_client = docker.from_env() 24 | return _lib_client 25 | 26 | 27 | def docker_unavailable(): 28 | try: 29 | client = get_lib_client() 30 | client.ping() 31 | except docker.errors.DockerException: 32 | return True 33 | return False 34 | 35 | 36 | def selinux_enabled(): 37 | try: 38 | seenabled_cmd = subprocess.run("selinuxenabled") 39 | except FileNotFoundError: 40 | return False 41 | return seenabled_cmd.returncode == 0 42 | 43 | 44 | @pytest.mark.skipif(docker_unavailable(), reason="docker service is not available") 45 | class DockerClientTests(unittest.TestCase): 46 | def setUp(self): 47 | self.network_cleanup = [] 48 | self.container_cleanup = [] 49 | self.image_cleanup = [] 50 | 51 | def tearDown(self): 52 | lib_client = get_lib_client() 53 | for container_name in self.container_cleanup: 54 | container = lib_client.containers.get(container_name) 55 | try: 56 | container.kill() 57 | except docker.errors.APIError: 58 | pass 59 | container.remove(force=True) 60 | for network_name in self.network_cleanup: 61 | network = lib_client.networks.get(network_name) 62 | network.remove() 63 | for image_name in self.image_cleanup: 64 | network = lib_client.images.remove(image_name) 65 | 66 | def test_create_remove_network(self): 67 | client = DockerClient.get_client() 68 | client.create_network("miniboss-test-network") 69 | lib_client = get_lib_client() 70 | networks = lib_client.networks.list() 71 | assert "miniboss-test-network" in [n.name for n in networks] 72 | client.remove_network("miniboss-test-network") 73 | networks = lib_client.networks.list() 74 | assert "miniboss-test-network" not in [n.name for n in networks] 75 | 76 | def test_run_service_on_network(self): 77 | client = DockerClient.get_client() 78 | client.create_network("miniboss-test-network") 79 | self.network_cleanup.append("miniboss-test-network") 80 | 81 | class TestService(miniboss.Service): 82 | name = "test-service" 83 | image = "nginx" 84 | ports = {80: 8085} 85 | 86 | service = TestService() 87 | container_name = client.run_service_on_network( 88 | "miniboss-test-service", 89 | service, 90 | Network(name="miniboss-test-network", id=""), 91 | ) 92 | self.container_cleanup.append(container_name) 93 | resp = requests.get("http://localhost:8085") 94 | assert resp.status_code == 200 95 | lib_client = get_lib_client() 96 | containers = lib_client.containers.list() 97 | assert container_name in [c.name for c in containers] 98 | network = lib_client.networks.get("miniboss-test-network") 99 | assert len(network.containers) == 1 100 | assert network.containers[0].name == container_name 101 | 102 | def test_service_entrypoint(self): 103 | client = DockerClient.get_client() 104 | client.create_network("miniboss-test-network") 105 | self.network_cleanup.append("miniboss-test-network") 106 | 107 | class TestService(miniboss.Service): 108 | name = "test-service" 109 | image = "python:3.7" 110 | ports = {8000: 8085} 111 | entrypoint = ["python3", "-m", "http.server"] 112 | 113 | service = TestService() 114 | container_name = client.run_service_on_network( 115 | "miniboss-test-service", 116 | service, 117 | Network(name="miniboss-test-network", id=""), 118 | ) 119 | self.container_cleanup.append(container_name) 120 | resp = requests.get("http://localhost:8085") 121 | assert resp.status_code == 200 122 | 123 | def test_service_cmd(self): 124 | client = DockerClient.get_client() 125 | client.create_network("miniboss-test-network") 126 | self.network_cleanup.append("miniboss-test-network") 127 | 128 | class TestService(miniboss.Service): 129 | name = "test-service" 130 | image = "python:3.7" 131 | ports = {8000: 8085} 132 | cmd = ["python3", "-m", "http.server"] 133 | 134 | service = TestService() 135 | container_name = client.run_service_on_network( 136 | "miniboss-test-service", 137 | service, 138 | Network(name="miniboss-test-network", id=""), 139 | ) 140 | self.container_cleanup.append(container_name) 141 | resp = requests.get("http://localhost:8085") 142 | assert resp.status_code == 200 143 | 144 | def test_service_user(self): 145 | client = DockerClient.get_client() 146 | client.create_network("miniboss-test-network") 147 | self.network_cleanup.append("miniboss-test-network") 148 | context = tempfile.mkdtemp() 149 | with open(os.path.join(context, "Dockerfile"), "w") as dockerfile: 150 | # The Dockerfile has no USER command; it only creates the user but 151 | # does not set it 152 | dockerfile.write( 153 | """FROM python:3.7 154 | RUN useradd -m --uid 1000 dockeruser 155 | RUN pip install flask 156 | COPY app.py . 157 | CMD ["python3", "app.py"]""" 158 | ) 159 | with open(os.path.join(context, "app.py"), "w") as app_file: 160 | app_file.write( 161 | """ 162 | import getpass 163 | from flask import Flask 164 | app = Flask('the-app') 165 | 166 | @app.route("/") 167 | def index(): 168 | return getpass.getuser() 169 | 170 | app.run(host='0.0.0.0', port=8080) 171 | """ 172 | ) 173 | lib_client = get_lib_client() 174 | lib_client.images.build(path=context, tag="user-container") 175 | self.image_cleanup.append("user-container") 176 | 177 | class TestService(miniboss.Service): 178 | name = "test-service" 179 | image = "user-container" 180 | ports = {8080: 8080} 181 | user = "dockeruser" 182 | 183 | service = TestService() 184 | container_name = client.run_service_on_network( 185 | "miniboss-test-service", 186 | service, 187 | Network(name="miniboss-test-network", id=""), 188 | ) 189 | self.container_cleanup.append(container_name) 190 | resp = requests.get("http://localhost:8080") 191 | assert resp.status_code == 200 192 | assert resp.text == "dockeruser" 193 | 194 | @pytest.mark.skipif( 195 | selinux_enabled(), 196 | reason="This test will not run if seelinux enabled, disable" 197 | 'it with `su -c "setenforce 0"`, and then re-enable it', 198 | ) 199 | def test_run_service_volume_mount(self): 200 | client = DockerClient.get_client() 201 | client.create_network("miniboss-test-network") 202 | self.network_cleanup.append("miniboss-test-network") 203 | # ---------------------------- 204 | context = tempfile.mkdtemp() 205 | with open(os.path.join(context, "Dockerfile"), "w") as dockerfile: 206 | dockerfile.write( 207 | """FROM python:3.7 208 | RUN useradd -m --uid 1000 dockeruser 209 | USER 1000:1000 210 | WORKDIR /home/dockeruser 211 | RUN pip install flask 212 | COPY app.py . 213 | CMD ["python3", "app.py"]""" 214 | ) 215 | with open(os.path.join(context, "app.py"), "w") as app_file: 216 | app_file.write( 217 | """ 218 | from flask import Flask 219 | app = Flask('the-app') 220 | 221 | @app.route("/") 222 | def index(): 223 | with open("/mnt/volume1/key.txt", 'r') as key_file: 224 | retval = key_file.read() 225 | return retval 226 | 227 | app.run(host='0.0.0.0', port=8080) 228 | """ 229 | ) 230 | lib_client = get_lib_client() 231 | lib_client.images.build(path=context, tag="mounted-container") 232 | self.image_cleanup.append("mounted-container") 233 | # ---------------------------- 234 | mount_dir = tempfile.mkdtemp() 235 | key = str(uuid.uuid4()) 236 | with open(os.path.join(mount_dir, "key.txt"), "w") as keyfile: 237 | keyfile.write(key) 238 | 239 | class TestService(miniboss.Service): 240 | name = "test-service" 241 | image = "mounted-container" 242 | ports = {8080: 8080} 243 | volumes = {mount_dir: {"bind": "/mnt/volume1", "mode": "ro"}} 244 | 245 | service = TestService() 246 | container_name = client.run_service_on_network( 247 | "miniboss-test-service", 248 | service, 249 | Network(name="miniboss-test-network", id=""), 250 | ) 251 | self.container_cleanup.append(container_name) 252 | resp = requests.get("http://localhost:8080") 253 | assert resp.status_code == 200 254 | assert resp.text == key 255 | 256 | def test_check_image_invalid_url(self): 257 | client = DockerClient.get_client() 258 | with pytest.raises(exceptions.DockerException): 259 | client.check_image("somerepothatdoesntexist.org/imagename:imagetag") 260 | 261 | def test_check_image_missing_tag(self): 262 | lib_client = get_lib_client() 263 | lib_client.images.pull("registry:2") 264 | hub_container = lib_client.containers.run( 265 | "registry:2", detach=True, ports={5000: 5000} 266 | ) 267 | self.container_cleanup.append(hub_container.name) 268 | client = DockerClient.get_client() 269 | with pytest.raises(exceptions.DockerException): 270 | client.check_image("localhost:5000/this-repo:not-exist") 271 | 272 | def test_check_image_download_from_repo(self): 273 | lib_client = get_lib_client() 274 | # Start a local instance of the container 275 | lib_client.images.pull("registry:2") 276 | hub_container = lib_client.containers.run( 277 | "registry:2", detach=True, ports={5000: 5000} 278 | ) 279 | self.container_cleanup.append(hub_container.name) 280 | # Sanity check: the registry container should be running 281 | assert lib_client.containers.get(hub_container.id).status == "running" 282 | # Now build a container that's tagged for the local registry 283 | context = tempfile.mkdtemp() 284 | with open(os.path.join(context, "Dockerfile"), "w") as dockerfile: 285 | dockerfile.write( 286 | """FROM nginx 287 | COPY index.html /usr/share/nginx/html""" 288 | ) 289 | with open(os.path.join(context, "index.html"), "w") as index: 290 | index.write("ALL GOOD") 291 | lib_client.images.build(path=context, tag="localhost:5000/allis:good") 292 | lib_client.images.push("localhost:5000/allis:good") 293 | # Let's delete the image from the local cache so that it has to be downloaded 294 | lib_client.images.remove("localhost:5000/allis:good") 295 | client = DockerClient.get_client() 296 | images = lib_client.images.list(name="localhost:5000/allis") 297 | assert len(images) == 0 298 | client.check_image("localhost:5000/allis:good") 299 | images = lib_client.images.list(name="localhost:5000/allis") 300 | assert len(images) == 1 301 | # Should be killed on tearDown 302 | self.image_cleanup.append("localhost:5000/allis:good") 303 | 304 | def test_run_container(self): 305 | client = DockerClient.get_client() 306 | client.create_network("miniboss-test-network") 307 | self.network_cleanup.append("miniboss-test-network") 308 | 309 | class TestService(miniboss.Service): 310 | name = "test-service" 311 | image = "nginx" 312 | ports = {80: 8085} 313 | 314 | service = TestService() 315 | container_name = client.run_service_on_network( 316 | "miniboss-test-service", 317 | service, 318 | Network(name="miniboss-test-network", id=""), 319 | ) 320 | self.container_cleanup.append(container_name) 321 | resp = requests.get("http://localhost:8085") 322 | assert resp.status_code == 200 323 | lib_client = get_lib_client() 324 | container = lib_client.containers.get(container_name) 325 | container.stop() 326 | # Let's make sure it's not running 327 | with pytest.raises(Exception): 328 | resp = requests.get("http://localhost:8085") 329 | # and restart it 330 | client.run_container(container.id) 331 | resp = requests.get("http://localhost:8085") 332 | assert resp.status_code == 200 333 | 334 | def test_print_error_on_container_dead(self): 335 | lib_client = get_lib_client() 336 | context = tempfile.mkdtemp() 337 | with open(os.path.join(context, "Dockerfile"), "w") as dockerfile: 338 | dockerfile.write( 339 | """FROM bash 340 | WORKDIR / 341 | COPY fail.sh / 342 | RUN chmod +x /fail.sh 343 | CMD ["/fail.sh"]""" 344 | ) 345 | with open(os.path.join(context, "fail.sh"), "w") as index: 346 | index.write("echo 'Going down' && exit 1") 347 | lib_client.images.build(path=context, tag="crashing-container") 348 | self.image_cleanup.append("crashing-container") 349 | client = DockerClient.get_client() 350 | client.create_network("miniboss-test-network") 351 | self.network_cleanup.append("miniboss-test-network") 352 | 353 | class FailingService(miniboss.Service): 354 | name = "failing-service" 355 | image = "crashing-container" 356 | 357 | service = FailingService() 358 | with pytest.raises(exceptions.ContainerStartException) as exception_context: 359 | client.run_service_on_network( 360 | "miniboss-failing-service", 361 | service, 362 | Network(name="miniboss-test-network", id=""), 363 | ) 364 | exception = exception_context.value 365 | self.container_cleanup.append(exception.container_name) 366 | assert exception.logs == "Going down\n" 367 | 368 | def test_build_image(self): 369 | lib_client = get_lib_client() 370 | context = tempfile.mkdtemp() 371 | with open(os.path.join(context, "Dockerfile"), "w") as dockerfile: 372 | dockerfile.write( 373 | """FROM nginx 374 | COPY index.html /usr/share/nginx/html""" 375 | ) 376 | with open(os.path.join(context, "index.html"), "w") as index: 377 | index.write("ALL GOOD") 378 | client = DockerClient.get_client() 379 | client.build_image(context, "Dockerfile", "temporary-tag") 380 | images = lib_client.images.list(name="temporary-tag") 381 | assert len(images) == 1 382 | -------------------------------------------------------------------------------- /miniboss/services.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import time 5 | from collections import Counter, deque 6 | from collections.abc import Mapping 7 | from typing import Any, Callable, Union 8 | 9 | from miniboss import types 10 | from miniboss.context import Context 11 | from miniboss.docker_client import DockerClient 12 | from miniboss.exceptions import ServiceDefinitionError, ServiceLoadError 13 | from miniboss.running_context import RunningContext 14 | from miniboss.types import Network, Options 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | KEYCLOAK_PORT = 8090 19 | OSTKREUZ_PORT = 8080 20 | ALLOWED_STOP_SIGNALS = ["SIGINT", "SIGTERM", "SIGKILL", "SIGQUIT"] 21 | 22 | 23 | class ServiceMeta(type): 24 | # pylint: disable=too-many-branches,too-many-statements 25 | def __new__(cls, name, bases, attrdict): 26 | if not bases: 27 | return super().__new__(cls, name, bases, attrdict) 28 | if not isinstance(attrdict.get("name"), str) or attrdict["name"] == "": 29 | raise ServiceDefinitionError( 30 | f"Field 'name' of service class {name:s} must be a non-empty string" 31 | ) 32 | if not isinstance(attrdict.get("image"), str) or attrdict["image"] == "": 33 | raise ServiceDefinitionError( 34 | f"Field 'image' of service class {name:s} must be a non-empty string" 35 | ) 36 | if "ports" in attrdict and not isinstance(attrdict["ports"], Mapping): 37 | raise ServiceDefinitionError( 38 | f"Field 'ports' of service class {name:s} must be a mapping" 39 | ) 40 | if "env" in attrdict and not isinstance(attrdict["env"], Mapping): 41 | raise ServiceDefinitionError( 42 | f"Field 'env' of service class {name:s} must be a mapping" 43 | ) 44 | if "always_start_new" in attrdict and not isinstance( 45 | attrdict["always_start_new"], bool 46 | ): 47 | raise ServiceDefinitionError( 48 | f"Field 'always_start_new' of service class {name:s} must be a boolean" 49 | ) 50 | if "build_from" in attrdict: 51 | build_dir = attrdict["build_from"] 52 | if not isinstance(build_dir, str) or build_dir == "": 53 | raise ServiceDefinitionError( 54 | f"Field 'build_from' of service class {name:s} must be a non-empty string" 55 | ) 56 | if "dockerfile" in attrdict: 57 | dockerfile = attrdict["dockerfile"] 58 | if not isinstance(dockerfile, str) or dockerfile == "": 59 | raise ServiceDefinitionError( 60 | f"Field 'dockerfile' of service class {name:s} must be a non-empty string" 61 | ) 62 | if "stop_signal" in attrdict: 63 | signal_name = attrdict["stop_signal"] 64 | if signal_name not in ALLOWED_STOP_SIGNALS: 65 | raise ServiceDefinitionError( 66 | f"Stop signal not allowed: {signal_name:s}" 67 | ) 68 | if "entrypoint" in attrdict: 69 | entrypoint = attrdict["entrypoint"] 70 | if isinstance(entrypoint, list): 71 | if not all(isinstance(x, str) for x in entrypoint): 72 | msg = ( 73 | f"Field 'entrypoint' of service class {name:s} must " 74 | "be a string or list of strings" 75 | ) 76 | raise ServiceDefinitionError(msg) 77 | elif not isinstance(entrypoint, str): 78 | raise ServiceDefinitionError( 79 | f"Field 'entrypoint' of service class {name:s} must " 80 | "be a string or list of strings" 81 | ) 82 | if "cmd" in attrdict: 83 | cmd = attrdict["cmd"] 84 | if isinstance(cmd, list): 85 | if not all(isinstance(x, str) for x in cmd): 86 | raise ServiceDefinitionError( 87 | f"Field 'cmd' of service class {name:s} must " 88 | "be a string or list of strings" 89 | ) 90 | elif not isinstance(cmd, str): 91 | raise ServiceDefinitionError( 92 | f"Field 'cmd' of service class {name:s} must " 93 | "be a string or list of strings" 94 | ) 95 | if "user" in attrdict: 96 | user = attrdict["user"] 97 | if not isinstance(user, str): 98 | raise ServiceDefinitionError( 99 | f"Field 'user' of service class {name:s} must be a string" 100 | ) 101 | if "volumes" in attrdict: 102 | volumes = attrdict["volumes"] 103 | if isinstance(volumes, list): 104 | if not all(isinstance(x, str) for x in volumes): 105 | raise ServiceDefinitionError( 106 | "Volumes have to be defined either as a list of strings or a dict" 107 | ) 108 | elif isinstance(volumes, dict): 109 | if not all(isinstance(x, str) for x in volumes.keys()): 110 | raise ServiceDefinitionError( 111 | "Volume definition keys have to be strings" 112 | ) 113 | for volume in volumes.values(): 114 | if not isinstance(volume, dict): 115 | raise ServiceDefinitionError( 116 | "Volume definition values have to be dicts" 117 | ) 118 | if not isinstance(volume.get("bind"), str): 119 | raise ServiceDefinitionError( 120 | "Volume definitions have to specify 'bind' key" 121 | ) 122 | else: 123 | raise ServiceDefinitionError( 124 | "Volumes have to be defined either as a list of strings or a dict" 125 | ) 126 | return super().__new__(cls, name, bases, attrdict) 127 | 128 | 129 | class Service(metaclass=ServiceMeta): 130 | name: str = "" 131 | image: str = "" 132 | dependencies: list[Service] = [] 133 | _dependants: list[Service] = [] 134 | ports: dict[int, int] = {} 135 | env: dict[str, Any] = {} 136 | always_start_new = False 137 | stop_signal = "SIGTERM" 138 | build_from = None 139 | dockerfile = "Dockerfile" 140 | entrypoint: str = "" 141 | cmd: str = "" 142 | user: str = "" 143 | volumes: Union[list[str], dict[str, dict[str, str]]] = {} 144 | 145 | # pylint: disable=no-self-use 146 | def ping(self) -> bool: 147 | return True 148 | 149 | def pre_start(self) -> None: 150 | pass 151 | 152 | def post_start(self) -> None: 153 | pass 154 | 155 | def __hash__(self) -> int: 156 | return hash(self.name) 157 | 158 | def __eq__(self, other) -> bool: 159 | return self.__class__ == other.__class__ and self.name == other.name 160 | 161 | def __repr__(self) -> str: 162 | return f"" 163 | 164 | def volume_def_to_binds(self) -> list[str]: 165 | if isinstance(self.volumes, dict): 166 | return [x["bind"] for x in self.volumes.values()] 167 | return [x.split(":")[1] for x in self.volumes] 168 | 169 | 170 | def connect_services(services: list[Service]) -> dict[str, Service]: 171 | name_counter: Counter[str] = Counter() 172 | for service in services: 173 | name_counter[service.name] += 1 174 | multiples = [name for name, count in name_counter.items() if count > 1] 175 | if multiples: 176 | raise ServiceLoadError(f'Repeated service names: {",".join(multiples)}') 177 | all_by_name = {service.name: service for service in services} 178 | for service in services: 179 | if isinstance(service, str): 180 | service = all_by_name[service] 181 | actual_deps = [] 182 | for dependency in service.dependencies: 183 | if isinstance(dependency, str): 184 | if dependency not in all_by_name: 185 | raise ServiceLoadError( 186 | f"Dependency {service.name:s} of service {dependency:s} not among services" 187 | ) 188 | dependency = all_by_name[dependency] 189 | actual_deps.append(dependency) 190 | service.dependencies = actual_deps 191 | for service in services: 192 | service._dependants = [x for x in services if service in x.dependencies] 193 | return all_by_name 194 | 195 | 196 | class ServiceCollection: 197 | def __init__(self): 198 | self.all_by_name = {} 199 | self._base_class = Service 200 | self.running_context = None 201 | self.excluded = [] 202 | 203 | def load_definitions(self): 204 | services = self._base_class.__subclasses__() 205 | if len(services) == 0: 206 | raise ServiceLoadError("No services defined") 207 | self.all_by_name = connect_services(list(service() for service in services)) 208 | self.check_circular_dependencies() 209 | 210 | def exclude_for_start(self, exclude): 211 | self.excluded = exclude 212 | for service in self.all_by_name.values(): 213 | if service.name in exclude: 214 | continue 215 | excluded_deps = [ 216 | dep.name for dep in service.dependencies if dep.name in exclude 217 | ] 218 | if excluded_deps: 219 | msg = f"{excluded_deps[0]} is to be excluded, but {service.name:s} depends on it" 220 | raise ServiceLoadError(msg) 221 | missing = [x for x in exclude if x not in self.all_by_name] 222 | if missing: 223 | multiple = "s" if len(missing) > 1 else "" 224 | msg = f"Service{multiple} to be excluded, but not defined: {','.join(missing)}" 225 | raise ServiceLoadError(msg) 226 | for name in exclude: 227 | self.all_by_name.pop(name) 228 | 229 | def exclude_for_stop(self, exclude): 230 | self.excluded = exclude 231 | for service_name in exclude: 232 | service = self.all_by_name[service_name] 233 | deps_to_be_stopped = [ 234 | dep.name for dep in service.dependencies if dep.name not in exclude 235 | ] 236 | if deps_to_be_stopped: 237 | msg = f"{deps_to_be_stopped[0]} is to be stopped, but {service.name} depends on it" 238 | raise ServiceLoadError(msg) 239 | self.all_by_name.pop(service_name) 240 | 241 | def check_circular_dependencies(self): 242 | with_dependencies = [ 243 | x for x in self.all_by_name.values() if x.dependencies != [] 244 | ] 245 | # pylint: disable=cell-var-from-loop 246 | for service in with_dependencies: 247 | start = service.name 248 | count = 0 249 | 250 | def go_up_dependencies(checked): 251 | nonlocal count 252 | count += 1 253 | for dependency in checked.dependencies: 254 | if dependency.name == start: 255 | raise ServiceLoadError("Circular dependency detected") 256 | if count == len(self.all_by_name): 257 | return 258 | go_up_dependencies(dependency) 259 | 260 | go_up_dependencies(service) 261 | 262 | def __len__(self): 263 | return len(self.all_by_name) 264 | 265 | def check_can_be_built(self, service_name): 266 | if not service_name in self.all_by_name: 267 | raise ServiceDefinitionError(f"No such service: {service_name}") 268 | service = self.all_by_name[service_name] 269 | if not service.build_from: 270 | msg = ( 271 | f"Service {service.name} cannot be built: No build directory specified" 272 | ) 273 | raise ServiceDefinitionError(msg) 274 | 275 | def start_all(self, options: Options) -> list[str]: 276 | docker = DockerClient.get_client() 277 | network = docker.create_network(options.network.name) 278 | options.network.id = network.id 279 | self.running_context = RunningContext(self.all_by_name, options) 280 | while not self.running_context.done: 281 | for agent in self.running_context.ready_to_start: 282 | agent.start_service() 283 | time.sleep(0.01) 284 | failed = [] 285 | if self.running_context.failed_services: 286 | failed = [x.name for x in self.running_context.failed_services] 287 | logger.error("Failed to start following services: %s", ",".join(failed)) 288 | return [x for x in self.all_by_name.keys() if x not in failed] 289 | 290 | def stop_all(self, options: Options) -> list[str]: 291 | docker = DockerClient.get_client() 292 | self.running_context = RunningContext(self.all_by_name, options) 293 | stopped = [] 294 | while not (self.running_context.done or self.running_context.failed_services): 295 | for agent in self.running_context.ready_to_stop: 296 | agent.stop_service() 297 | stopped.append(agent.service.name) 298 | time.sleep(0.01) 299 | if options.remove and not self.excluded: 300 | docker.remove_network(options.network.name) 301 | return stopped 302 | 303 | def update_for_base_service(self, service_name): 304 | if service_name not in self.all_by_name: 305 | raise ServiceLoadError(f"No such service: {service_name}") 306 | queue = deque() 307 | queue.append(self.all_by_name[service_name]) 308 | required = [] 309 | while queue: 310 | service = queue.popleft() 311 | required.append(service) 312 | for dependant in service._dependants: 313 | if dependant not in queue and dependant not in required: 314 | queue.append(dependant) 315 | self.all_by_name = {service.name: service for service in required} 316 | 317 | 318 | SingleServiceHookType = Callable[[str], Any] 319 | 320 | ServicesHookType = Callable[[list[str]], Any] 321 | 322 | 323 | def noop(*_, **__): 324 | pass 325 | 326 | 327 | _start_services_hook: ServicesHookType = noop 328 | 329 | 330 | def on_start_services(hook_func: ServicesHookType): 331 | global _start_services_hook 332 | _start_services_hook = hook_func 333 | 334 | 335 | def start_services(maindir: str, exclude: list[str], network_name: str, timeout: int): 336 | types.update_group_name(maindir) 337 | Context.load_from(maindir) 338 | collection = ServiceCollection() 339 | collection.load_definitions() 340 | collection.exclude_for_start(exclude) 341 | network_name = network_name or f"miniboss-{types.group_name}" 342 | options = Options( 343 | network=Network(name=network_name, id=""), 344 | timeout=timeout, 345 | remove=False, 346 | run_dir=maindir, 347 | build=[], 348 | ) 349 | service_names = collection.start_all(options) 350 | logger.info("Started services: %s", ", ".join(service_names)) 351 | Context.save_to(maindir) 352 | try: 353 | _start_services_hook(service_names) 354 | except KeyboardInterrupt: 355 | logger.info("Interrupted on_start_services hook") 356 | return 357 | except: # pylint: disable=bare-except 358 | logger.exception("Error running on_start_services hook") 359 | 360 | 361 | _stop_services_hook: ServicesHookType = noop 362 | 363 | 364 | def on_stop_services(hook_func: ServicesHookType): 365 | global _stop_services_hook 366 | _stop_services_hook = hook_func 367 | 368 | 369 | def stop_services( 370 | maindir: str, excluded: list[str], network_name: str, remove: bool, timeout: int 371 | ): 372 | types.update_group_name(maindir) 373 | logger.info( 374 | "Stopping services (excluded: %s)", 375 | "none" if not excluded else ",".join(excluded), 376 | ) 377 | network_name = network_name or f"miniboss-{types.group_name}" 378 | options = Options( 379 | network=Network(name=network_name, id=""), 380 | timeout=timeout, 381 | remove=remove, 382 | run_dir=maindir, 383 | build=[], 384 | ) 385 | collection = ServiceCollection() 386 | collection.load_definitions() 387 | collection.exclude_for_stop(excluded) 388 | stopped = collection.stop_all(options) 389 | if remove: 390 | Context.remove_file(maindir) 391 | try: 392 | _stop_services_hook(stopped) 393 | except KeyboardInterrupt: 394 | logger.info("Interrupted on_stop_services hook") 395 | return 396 | except: # pylint: disable=bare-except 397 | logger.exception("Error running on_stop_services hook") 398 | 399 | 400 | _reload_service_hook: SingleServiceHookType = noop 401 | 402 | 403 | def on_reload_service(hook_func: SingleServiceHookType): 404 | global _reload_service_hook 405 | _reload_service_hook = hook_func 406 | 407 | 408 | # pylint: disable=too-many-arguments 409 | def reload_service( 410 | maindir: str, service: str, network_name: str, remove: bool, timeout: int 411 | ): 412 | types.update_group_name(maindir) 413 | network_name = network_name or f"miniboss-{types.group_name}" 414 | options = Options( 415 | network=Network(name=network_name, id=""), 416 | timeout=timeout, 417 | remove=remove, 418 | run_dir=maindir, 419 | build=[service], 420 | ) 421 | stop_collection = ServiceCollection() 422 | stop_collection.load_definitions() 423 | stop_collection.check_can_be_built(service) 424 | stop_collection.update_for_base_service(service) 425 | stop_collection.stop_all(options) 426 | # We don't need to do this earlier, as the context is not used by the stop 427 | # functionality 428 | Context.load_from(maindir) 429 | start_collection = ServiceCollection() 430 | start_collection.load_definitions() 431 | start_collection.start_all(options) 432 | Context.save_to(maindir) 433 | try: 434 | _reload_service_hook(service) 435 | except KeyboardInterrupt: 436 | logger.info("Interrupted on_stop_services hook") 437 | return 438 | except: # pylint: disable=bare-except 439 | logger.exception("Error running on_stop_services hook") 440 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![afroisalreadyinu](https://circleci.com/gh/afroisalreadyinu/miniboss.svg?style=svg)](https://app.circleci.com/pipelines/github/afroisalreadyinu/miniboss) 2 | 3 | [![PyPI version](https://badge.fury.io/py/miniboss.svg)](https://badge.fury.io/py/miniboss) 4 | 5 | 6 | 7 | # miniboss 8 | 9 | miniboss is a Python application for locally running a collection of 10 | interdependent docker services, individually rebuilding and restarting them, and 11 | managing application state with lifecycle hooks. Services definitions can be 12 | written in Python, allowing the use of programming logic instead of markup. 13 | 14 | ## Why not docker-compose? 15 | 16 | First and foremost, good old Python instead of YAML. `docker-compose` is in the 17 | school of yaml-as-service-description, which means that going beyond a static 18 | description of a service set necessitates templates, or some kind of scripting. 19 | One could just as well use a full-blown programming language, while trying to 20 | keep simple things simple. Another thing sorely missing in `docker-compose` is 21 | lifecycle hooks, i.e. a mechanism whereby scripts can be executed when the state 22 | of a container changes. Lifecycle hooks have been 23 | [requested](https://github.com/docker/compose/issues/1809) 24 | [multiple](https://github.com/docker/compose/issues/5764) 25 | [times](https://github.com/compose-spec/compose-spec/issues/84), but were not 26 | deemed to be in the domain of `docker-compose`. 27 | 28 | ## Installation 29 | 30 | miniboss is [on PyPi](https://pypi.org/project/miniboss/); you can install it 31 | with the following: 32 | 33 | ``` 34 | pip install miniboss 35 | ``` 36 | 37 | ## Usage 38 | 39 | Here is a very simple service specification: 40 | 41 | ```python 42 | #! /usr/bin/env python3 43 | import miniboss 44 | 45 | miniboss.group_name('readme-demo') 46 | 47 | class Database(miniboss.Service): 48 | name = "appdb" 49 | image = "postgres:10.6" 50 | env = {"POSTGRES_PASSWORD": "dbpwd", 51 | "POSTGRES_USER": "dbuser", 52 | "POSTGRES_DB": "appdb" } 53 | ports = {5432: 5433} 54 | 55 | class Application(miniboss.Service): 56 | name = "python-todo" 57 | image = "afroisalreadyin/python-todo:0.0.1" 58 | env = {"DB_URI": "postgresql://dbuser:dbpwd@appdb:5432/appdb"} 59 | dependencies = ["appdb"] 60 | ports = {8080: 8080} 61 | stop_signal = "SIGINT" 62 | 63 | if __name__ == "__main__": 64 | miniboss.cli() 65 | ``` 66 | 67 | The first use of miniboss is in the call to `miniboss.group_name`, which 68 | specifies a name for this group of services. If you don't set it, sluggified 69 | form of the directory name will be used. Group name is used to identify the 70 | services and the network defined in a miniboss file. Setting it manually to a 71 | non-default value will allow miniboss to manage multiple collections in the same 72 | directory. 73 | 74 | A **service** is defined by subclassing `miniboss.Service` and overriding, in 75 | the minimal case, the fields `image` and `name`. The `env` field specifies the 76 | environment variables. As in the case of the `appdb` service, you can use 77 | ordinary variables anywhere Python accepts them. The other available fields are 78 | explained in the section [Service definition 79 | fields](#service-definition-fields). In the [above example](#usage), we are 80 | creating two services: The application service `python-todo` (a simple Flask 81 | todo application defined in the `sample-apps` directory) depends on `appdb` (a 82 | Postgresql container), specified through the `dependencies` field. As in 83 | `docker-compose`, this means that `python-todo` will get started after `appdb` 84 | reaches running status. 85 | 86 | The `miniboss.cli` function is the main entry point; you need to call it in the 87 | main section of your script. Let's run the script above without arguments, which 88 | leads to the following output: 89 | 90 | ``` 91 | Usage: miniboss-main.py [OPTIONS] COMMAND [ARGS]... 92 | 93 | Options: 94 | --help Show this message and exit. 95 | 96 | Commands: 97 | start 98 | stop 99 | ``` 100 | 101 | We can start our small collection of services by running `./miniboss-main.py 102 | start`. After spitting out some logging text, you will see that starting the 103 | containers failed, with the `python-todo` service throwing an error that it 104 | cannot reach the database. The reason for this error is that the Postgresql 105 | process has started, but is still initializing, and does not accept connections 106 | yet. The standard way of dealing with this issue is to include backoff code in 107 | your application that checks on the database port regularly, until the 108 | connection is accepted. `miniboss` offers an alternative with [lifecycle 109 | events](#lifecycle-events). For the time being, you can simply rerun 110 | `./miniboss-main.py start`, which will restart only the `python-todo` service, 111 | as the other one is already running. You should be able to navigate to 112 | `http://localhost:8080` and view the todo app page. 113 | 114 | You can also exclude services from the list of services to be started with the 115 | `--exclude` argument; `./miniboss-main.py start --exclude python-todo` will 116 | start only `appdb`. If you exclude a service that is depended on by another, you 117 | will get an error. If a service fails to start (i.e. container cannot be started 118 | or the lifecycle events fail), it and all the other services that depend on it 119 | are registered as failed. 120 | 121 | ### Stopping services 122 | 123 | Once you are done working with a collection, you can stop the running services 124 | with `miniboss-main.py stop`. This will stop the services in the reverse order 125 | of dependency, i.e. first `python-todo` and then `appdb`. Exclusion is possible 126 | also when stopping services with the same `--exclude` argument. Running 127 | `./miniboss-main.py stop --exclude appdb` will stop only the `python-todo` 128 | service. If you exclude a service whose dependency will be stopped, you will get 129 | an error. If, in addition to stopping the service containers, you want to remove 130 | them, include the option `--remove`. If you don't remove the containers, 131 | miniboss will restart the existing containers (modulo changes in service 132 | definition) instead of creating new ones the next time it's called with `start`. 133 | This behavior can be modified with the `always_start_new` field; see the details 134 | in [Service definition fields](#service-definition-fields). 135 | 136 | ### Reloading a service 137 | 138 | miniboss also allows you to reload a specific service by building a new 139 | container image from a directory. You need to provide the path to the directory 140 | in which the Dockerfile and build context of a service resides in order to use 141 | this feature. You can also provide an alternative Dockerfile name. Here is an 142 | example: 143 | 144 | ```python 145 | class Application(miniboss.Service): 146 | name = "python-todo" 147 | image = "afroisalreadyin/python-todo:0.0.1" 148 | env = {"DB_URI": "postgresql://dbuser:dbpwd@appdb:5432/appdb"} 149 | dependencies = ["appdb"] 150 | ports = {8080: 8080} 151 | build_from = "python-todo/" 152 | dockerfile = "Dockerfile" 153 | ``` 154 | 155 | The `build_from` option has to be a path relative to the main miniboss file. 156 | With such a service configuration, you can run `./miniboss-main.py reload 157 | python-todo`, which will cause miniboss to build the container image, stop the 158 | running service container, and restart the new image. Since [the 159 | context](#the-global-context) generated at start is saved in a file, any context 160 | values used in the service definition are available to the new container. 161 | 162 | ## Lifecycle events 163 | 164 | One of the differentiating feature of miniboss is lifecycle events, which are 165 | hooks that can be customized to execute code at certain points in a service's or 166 | the whole collection's lifecycle. 167 | 168 | ### Per-service events 169 | 170 | For per-service events, `miniboss.Service` has three methods that can be 171 | overridden in order to correctly change states and execute actions on the 172 | container: 173 | 174 | - **`Service.pre_start()`**: Executed before the service is started. Can be used 175 | for things like initializing mount directory contents or downloading online 176 | content. 177 | 178 | - **`Service.ping()`**: Executed repeatedly right after the service starts with 179 | a 0.1 second delay between executions. If this method does not return `True` 180 | within a given timeout value (can be set with the `--timeout` argument, 181 | default is 300 seconds), the service is registered as failed. Any exceptions 182 | in this method will be propagated, and also cause the service to fail. If 183 | there is already a service instance running, it is not pinged. 184 | 185 | - **`Service.post_start()`**: This method is executed after a successful `ping`. 186 | It can be used to prime a service by e.g. creating data on it, or bringing it 187 | to a certain state. You can also use the global context in this method; see 188 | [The global context](#the-global-context) for details. If there is already a 189 | service running, or an existing container image is started instead of creating 190 | a new one, this method is not called. 191 | 192 | These methods are [noop](https://en.wikipedia.org/wiki/NOP_(code)) by default. A 193 | service is not registered as properly started before lifecycle methods are 194 | executed successfully; only then are the dependent services started. 195 | 196 | The `ping` method is particularly useful if you want to avoid the situation 197 | described above, where a container starts, but the main process has not 198 | completed initializing before any dependent services start. Here is an example 199 | for how one would ping the `appdb` service to make sure the PostgreSQL database 200 | is accepting connections: 201 | 202 | ```python 203 | import psycopg2 204 | 205 | class Database(miniboss.Service): 206 | # fields same as above 207 | 208 | def ping(self): 209 | try: 210 | connection = psycopg2.connect("postgresql://dbuser:dbpwd@localhost:5433/appdb") 211 | cur = connection.cursor() 212 | cur.execute('SELECT 1') 213 | except psycopg2.OperationalError: 214 | return False 215 | else: 216 | return True 217 | ``` 218 | 219 | One thing to pay attention to is that, in the call to `psycopg2.connect`, we are 220 | using `localhost:5433` as host and port, whereas the `python-todo` environment 221 | variable `DBURI` has `appdb:5433` instead. This is because the `ping` method is 222 | executed on the host computer. The next section explains the details. 223 | 224 | ### Collection events 225 | 226 | It is possible to hook into collection change commands using the following 227 | hooks. You can call them on the base `miniboss` module and set a hook by passing 228 | it in as the sole argument, e.g. as follows: 229 | 230 | ```python 231 | import miniboss 232 | 233 | def print_services(service_list): 234 | print("Started ", ' '.join(service_list)) 235 | 236 | miniboss.on_start_services(print_services) 237 | ``` 238 | 239 | - **`on_start_services`** hook is called after the `miniboss.start` command is 240 | executed. The single argument is a list of the names of the services that were 241 | successfully started. 242 | 243 | - **`on_stop_services`** hook is called after the `miniboss.stop` command is 244 | executed. The single argument is a list of the services that were stopped. 245 | 246 | - **`on_reload_service`** hook is called after the `miniboss.reload` command is 247 | executed. The single argument is the name of the service that was reloaded. 248 | 249 | 250 | ## Ports and hosts 251 | 252 | miniboss starts services on an isolated bridge network, mapping no ports by 253 | default. The name of this service can be specified with the `--network-name` 254 | argument when starting a group. If it's not specified, the name will be 255 | generated from the group name by prefixing it with `miniboss-`. On the 256 | collection network, services can be contacted under the service name as 257 | hostname, on the ports they are listening on. The `appdb` Postgresql service 258 | [above](#usage), for example, can be contacted on the port 5432, the default 259 | port on which Postgresql listens. This is the reason the host part of the 260 | `DB_URI` environment variable on the `python-todo` service is `appdb:5432`. If 261 | you want to reach `appdb` on the port `5433` from the host system, which would 262 | be necessary to implement the `ping` method as above, you need to make this 263 | mapping explicit with the `ports` field of the service definition. This field 264 | accepts a dictionary of integer keys and values. The key is the service 265 | container port, and the value is the host port. In the case of `appdb`, the 266 | Postgresql port of the container is mapped to port 5433 on the local machine, in 267 | order not to collide with any local Postgresql instances. With this 268 | configuration, the `appdb` database can be accessed at `localhost:5433`. 269 | 270 | ### The global context 271 | 272 | The object `miniboss.Context`, derived from the standard dict class, can be used 273 | to store values that are accessible to other service definitions, especially in 274 | the `env` field. For example, if you create a user in the `post_start` method of 275 | a service, and would like to make the ID of this user available to a dependent 276 | service, you can set it on the context with `Context['user_id'] = user.id`. In 277 | the definition of the second service, you can refer to this value in a field 278 | with the standard Python keyword formatting syntax, as in the following: 279 | 280 | ```python 281 | class DependantService(miniboss.Service): 282 | # other fields 283 | env = {'USER_ID': '{user_id}'} 284 | ``` 285 | 286 | You can of course also programmatically access it as `Context['user_id']` once a 287 | value has been set. 288 | 289 | When a service collection is started, the generated context is saved in the file 290 | `.miniboss-context`, in order to be used when the same containers are restarted 291 | or a specific service is [reloaded](#reloading-a-service). 292 | 293 | ## Service definition fields 294 | 295 | - **`name`**: The name of the service. Must be non-empty and unique for one 296 | miniboss definition module. The container can be contacted on the network 297 | under this name; it must therefore be a valid hostname. 298 | 299 | - **`image`**: Container image of the service. Must be non-empty. You can use a 300 | repository URL here; if the image is not locally available, it will be pulled. 301 | You are highly advised to specify a tag, even if it's `latest`, because 302 | otherwise miniboss will not be able to identify which container image was used 303 | for a service, and start a new container each time. If the tag of the `image` 304 | is `latest`, and the `build_from` directory option is specified, the container 305 | image will be built each time the service is started. 306 | 307 | - **`entrypoint`**: Container entrypoint, the executable that is run when the 308 | container starts. See [Docker 309 | documentation](https://docs.docker.com/engine/reference/builder/#entrypoint) for 310 | details. 311 | 312 | - **`cmd`**: `CMD` option for a container. See [Docker 313 | documentation](https://docs.docker.com/engine/reference/builder/#cmd) for 314 | details. 315 | 316 | - **`user`**: `USER` option for a container See [Docker 317 | documentation](https://docs.docker.com/engine/reference/builder/#user) for 318 | details. 319 | 320 | - **`dependencies`**: A list of the dependencies of a service by name. If there 321 | are any invalid or circular dependencies, an exception will be raised. 322 | 323 | - **`env`**: Environment variables to be injected into the service container, as 324 | a dict. The values of this dict can contain extrapolations from the global 325 | context; these extrapolations are executed when the service starts. 326 | 327 | - **`ports`**: A mapping of the ports that must be exposed on the running host. 328 | Keys are ports local to the container, values are the ports of the running 329 | host. See [Ports and hosts](#ports-and-hosts) for more details on networking. 330 | 331 | - **`volumes`**: Directories to be mounted inside the services as a volume, on 332 | which mount points. The value of `volumes` can be either a list of strings, in 333 | the format `"directory:mount_point:mode"`, or in the dictionary format 334 | `{directory: {"bind": mount_point, "mode": mode}}`. In both cases, `mode` is 335 | optional. See the [Using 336 | volumes](https://docker-py.readthedocs.io/en/stable/api.html#docker.api.container.ContainerApiMixin.create_container) 337 | section of Docker Python SDK documentation for details. 338 | 339 | - **`always_start_new`**: Whether to create a new container each time a service 340 | is started or restart an existing but stopped container. Default value is 341 | `False`, meaning that by default existing container will be restarted. 342 | 343 | - **`stop_signal`**: Which stop signal Docker should use to stop the container, 344 | by name (not by integer value, so don't use values from the `signal` standard 345 | library module here). Default is `SIGTERM`. Accepted values are `SIGINT`, 346 | `SIGTERM`, `SIGKILL` and `SIGQUIT`. 347 | 348 | - **`build_from`**: The directory from which a service can be reloaded. It 349 | should be either absolute, or relative to the main script. Required if you 350 | want to be able to reload a service. If this option is specified, and the tag 351 | of the `image` option is `latest`, the container image will be built each time 352 | the service is started. 353 | 354 | - **`dockerfile`**: Dockerfile to use when building a service from the 355 | `build_from` directory. Default is `Dockerfile`. 356 | 357 | ## Release notes 358 | 359 | ### 0.3.0 360 | 361 | - Linting 362 | - Pull container image if it doesn't exist 363 | - Integration tests 364 | - Mounting volumes 365 | - Pre-start lifetime event 366 | 367 | ### 0.4.0 368 | 369 | - Don't fail on start if excluded services depend on each other 370 | - Destroy service if it cannot be started 371 | - Log when custom post_start is done 372 | - Don't start new if int-string env keys don't differ 373 | - Don't run pre-start if container found 374 | - Multiple clusters on single host with group id 375 | - Build container if tag doesn't exist and it has `build_from` 376 | - Better pypi readme with release notes 377 | 378 | ### 0.4.1 379 | 380 | - Tests for CLI commands 381 | - Collection lifecycle hooks 382 | 383 | ### 0.4.2 384 | 385 | - Removed group name requirement 386 | - Logging fixes 387 | - Sample app fixes 388 | 389 | ### 0.4.3 390 | 391 | - Entrypoint, cmd and user fields on service 392 | - Type hints 393 | - Use tbump for version bumping 394 | 395 | ### 0.4.3 396 | 397 | - Corrected docker lcient library version in dependencies 398 | 399 | ## Todos 400 | 401 | - [ ] User attrs properly with types 402 | - [ ] Add stop-only command 403 | - [ ] Add start-only command 404 | - [ ] Making easier to test on the cloud?? 405 | - [ ] podman support 406 | - [ ] Run tests in container (how?) 407 | - [ ] Exporting environment values for use in shell 408 | - [ ] Running one-off containers 409 | - [ ] Configuration object extrapolation 410 | - [ ] Running tests once system started 411 | - [ ] Using context values in tests 412 | - [ ] Dependent test suites and setups 413 | -------------------------------------------------------------------------------- /tests/unit/test_service_agent.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import datetime 3 | from types import SimpleNamespace as Bunch 4 | from unittest.mock import patch 5 | 6 | import attr 7 | import pytest 8 | from common import ( 9 | DEFAULT_OPTIONS, 10 | FakeContainer, 11 | FakeDocker, 12 | FakeRunningContext, 13 | FakeService, 14 | ) 15 | 16 | from miniboss import context, service_agent, types 17 | from miniboss.service_agent import ( 18 | Actions, 19 | AgentStatus, 20 | ServiceAgent, 21 | ServiceAgentException, 22 | ) 23 | from miniboss.services import connect_services 24 | from miniboss.types import Network, Options, RunCondition 25 | 26 | 27 | class ServiceAgentTests(unittest.TestCase): 28 | def setUp(self): 29 | self.docker = FakeDocker.Instance = FakeDocker( 30 | {"the-network": "the-network-id"} 31 | ) 32 | service_agent.DockerClient = self.docker 33 | types.set_group_name("testing") 34 | 35 | def tearDown(self): 36 | types._unset_group_name() 37 | 38 | def test_can_start(self): 39 | services = connect_services( 40 | [ 41 | Bunch(name="service1", dependencies=[]), 42 | Bunch(name="service2", dependencies=["service1"]), 43 | ] 44 | ) 45 | agent = ServiceAgent(services["service2"], DEFAULT_OPTIONS, None) 46 | assert agent.can_start is False 47 | agent.process_service_started(services["service1"]) 48 | assert agent.can_start is True 49 | agent.status = AgentStatus.IN_PROGRESS 50 | assert agent.can_start is False 51 | 52 | def test_can_stop(self): 53 | services = connect_services( 54 | [ 55 | Bunch(name="service1", dependencies=[]), 56 | Bunch(name="service2", dependencies=["service1"]), 57 | ] 58 | ) 59 | agent = ServiceAgent(services["service1"], DEFAULT_OPTIONS, None) 60 | assert agent.can_stop is False 61 | agent.process_service_stopped(services["service2"]) 62 | assert agent.can_stop is True 63 | 64 | def test_action_property(self): 65 | service = Bunch(name="service1", dependencies=[], _dependants=[]) 66 | agent = ServiceAgent(service, DEFAULT_OPTIONS, None) 67 | assert agent.action is None 68 | with pytest.raises(ServiceAgentException): 69 | agent.action = "blah" 70 | agent.action = "start" 71 | assert agent.action == "start" 72 | 73 | def test_fail_if_action_not_set(self): 74 | service = Bunch(name="service1", dependencies=[], _dependants=[]) 75 | fake_context = FakeRunningContext() 76 | agent = ServiceAgent(service, DEFAULT_OPTIONS, fake_context) 77 | with pytest.raises(ServiceAgentException): 78 | agent.run() 79 | assert len(fake_context.failed_services) == 1 80 | assert fake_context.failed_services[0] is service 81 | 82 | def test_run_image(self): 83 | agent = ServiceAgent(FakeService(), DEFAULT_OPTIONS, None) 84 | agent.run_image() 85 | assert len(self.docker._services_started) == 1 86 | prefix, service, network = self.docker._services_started[0] 87 | assert prefix == "service1-testing" 88 | assert service.name == "service1" 89 | assert service.image == "not/used" 90 | assert network.name == "the-network" 91 | 92 | def test_run_image_extrapolate_env(self): 93 | service = FakeService() 94 | service.env = {"ENV_ONE": "http://{host}:{port:d}"} 95 | context.Context["host"] = "zombo.com" 96 | context.Context["port"] = 80 97 | agent = ServiceAgent(service, DEFAULT_OPTIONS, None) 98 | agent.run_image() 99 | assert len(self.docker._services_started) == 1 100 | _, service, _ = self.docker._services_started[0] 101 | assert service.env["ENV_ONE"] == "http://zombo.com:80" 102 | 103 | def test_agent_status_change_happy_path(self): 104 | class ServiceAgentTestSubclass(ServiceAgent): 105 | def ping(self): 106 | assert self.status == "in-progress" 107 | return super().ping() 108 | 109 | agent = ServiceAgentTestSubclass( 110 | FakeService(), DEFAULT_OPTIONS, FakeRunningContext() 111 | ) 112 | assert agent.status == "null" 113 | agent.start_service() 114 | agent.join() 115 | assert agent.status == "started" 116 | 117 | def test_agent_status_change_sad_path(self): 118 | class ServiceAgentTestSubclass(ServiceAgent): 119 | def ping(self): 120 | assert self.status == "in-progress" 121 | raise ValueError("I failed miserably") 122 | 123 | agent = ServiceAgentTestSubclass( 124 | FakeService(), DEFAULT_OPTIONS, FakeRunningContext() 125 | ) 126 | assert agent.status == "null" 127 | agent.start_service() 128 | agent.join() 129 | assert agent.status == "failed" 130 | 131 | def test_skip_if_running_on_same_network(self): 132 | service = FakeService() 133 | agent = ServiceAgent(service, DEFAULT_OPTIONS, None) 134 | self.docker._existing_containers = [ 135 | Bunch( 136 | status="running", 137 | name="{}-testing-123".format(service.name), 138 | network="the-network", 139 | ) 140 | ] 141 | agent.run_image() 142 | assert len(self.docker._services_started) == 0 143 | assert len(self.docker._existing_queried) == 1 144 | assert self.docker._existing_queried[0] == ( 145 | "service1-testing", 146 | Network(name="the-network", id="the-network-id"), 147 | ) 148 | 149 | def test_start_old_container_if_exists(self): 150 | service = FakeService() 151 | agent = ServiceAgent(service, DEFAULT_OPTIONS, None) 152 | self.docker._existing_containers = [ 153 | Bunch( 154 | status="exited", 155 | network="the-network", 156 | id="longass-container-id", 157 | image=Bunch(tags=[service.image]), 158 | attrs={"Config": {"Env": []}}, 159 | name="{}-testing-123".format(service.name), 160 | ) 161 | ] 162 | agent.run_image() 163 | assert len(self.docker._services_started) == 0 164 | assert self.docker._containers_ran == ["longass-container-id"] 165 | 166 | def test_start_new_container_if_old_has_different_tag(self): 167 | service = FakeService() 168 | agent = ServiceAgent(service, DEFAULT_OPTIONS, None) 169 | self.docker._existing_containers = [ 170 | Bunch( 171 | status="exited", 172 | network="the-network", 173 | id="longass-container-id", 174 | image=Bunch(tags=["different-tag"]), 175 | attrs={"Config": {"Env": []}}, 176 | name="{}-miniboss-123".format(service.name), 177 | ) 178 | ] 179 | agent.run_image() 180 | assert len(self.docker._services_started) == 1 181 | prefix, service, network = self.docker._services_started[0] 182 | assert prefix == "service1-testing" 183 | assert service.name == "service1" 184 | assert service.image == "not/used" 185 | assert network.name == "the-network" 186 | assert self.docker._containers_ran == [] 187 | 188 | def test_start_new_container_if_differing_env_value(self): 189 | service = FakeService() 190 | service.env = {"KEY": "some-value"} 191 | agent = ServiceAgent(service, DEFAULT_OPTIONS, None) 192 | self.docker._existing_containers = [ 193 | Bunch( 194 | status="exited", 195 | network="the-network", 196 | id="longass-container-id", 197 | image=Bunch(tags=[service.image]), 198 | attrs={"Config": {"Env": ["KEY=other-value"]}}, 199 | name="{}-miniboss-123".format(service.name), 200 | ) 201 | ] 202 | agent.run_image() 203 | assert len(self.docker._services_started) == 1 204 | prefix, service, network = self.docker._services_started[0] 205 | assert prefix == "service1-testing" 206 | assert service.name == "service1" 207 | assert service.image == "not/used" 208 | assert network.name == "the-network" 209 | assert self.docker._containers_ran == [] 210 | 211 | def test_start_existing_if_differing_env_value_type_but_not_string(self): 212 | service = FakeService() 213 | service.env = {"KEY": 12345} 214 | agent = ServiceAgent(service, DEFAULT_OPTIONS, None) 215 | self.docker._existing_containers = [ 216 | Bunch( 217 | status="exited", 218 | network="the-network", 219 | id="longass-container-id", 220 | image=Bunch(tags=[service.image]), 221 | attrs={"Config": {"Env": ["KEY=12345"]}}, 222 | name="{}-testing-123".format(service.name), 223 | ) 224 | ] 225 | agent.run_image() 226 | assert len(self.docker._services_started) == 0 227 | 228 | def test_start_new_if_always_start_new(self): 229 | service = FakeService() 230 | service.always_start_new = True 231 | options = Options( 232 | network=Network(name="the-network", id="the-network-id"), 233 | timeout=1, 234 | remove=True, 235 | run_dir="/etc", 236 | build=[], 237 | ) 238 | agent = ServiceAgent(service, options, None) 239 | restarted = False 240 | 241 | def start(): 242 | nonlocal restarted 243 | restarted = True 244 | 245 | self.docker._existing_containers = [ 246 | Bunch( 247 | status="exited", 248 | start=start, 249 | network="the-network", 250 | attrs={"Config": {"Env": []}}, 251 | name="{}-testing-123".format(service.name), 252 | ) 253 | ] 254 | agent.run_image() 255 | assert len(self.docker._services_started) == 1 256 | assert not restarted 257 | 258 | def test_build_on_start(self): 259 | fake_context = FakeRunningContext() 260 | fake_service = FakeService() 261 | fake_service.build_from = "the/service/dir" 262 | options = attr.evolve(DEFAULT_OPTIONS, build=[fake_service.name]) 263 | agent = ServiceAgent(fake_service, options, fake_context) 264 | agent.start_service() 265 | agent.join() 266 | assert len(self.docker._images_built) == 1 267 | 268 | def test_if_build_from_and_latest(self): 269 | fake_context = FakeRunningContext() 270 | fake_service = FakeService() 271 | fake_service.image = "service:latest" 272 | fake_service.build_from = "the/service/dir" 273 | agent = ServiceAgent(fake_service, DEFAULT_OPTIONS, fake_context) 274 | agent.start_service() 275 | agent.join() 276 | assert len(self.docker._images_built) == 1 277 | 278 | def test_pre_start_before_run(self): 279 | fake_context = FakeRunningContext() 280 | fake_service = FakeService() 281 | assert not fake_service.pre_start_called 282 | agent = ServiceAgent(fake_service, DEFAULT_OPTIONS, fake_context) 283 | agent.start_service() 284 | agent.join() 285 | assert fake_service.pre_start_called 286 | 287 | def test_ping_and_init_after_run(self): 288 | fake_context = FakeRunningContext() 289 | fake_service = FakeService() 290 | agent = ServiceAgent(fake_service, DEFAULT_OPTIONS, fake_context) 291 | agent.start_service() 292 | agent.join() 293 | assert len(fake_context.started_services) == 1 294 | assert fake_context.started_services[0].name == "service1" 295 | assert fake_service.ping_count == 1 296 | assert fake_service.init_called 297 | 298 | def test_no_pre_ping_or_init_if_running(self): 299 | service = FakeService() 300 | fake_context = FakeRunningContext() 301 | options = Options( 302 | network=Network(name="the-network", id="the-network-id"), 303 | timeout=1, 304 | remove=True, 305 | run_dir="/etc", 306 | build=[], 307 | ) 308 | agent = ServiceAgent(service, options, fake_context) 309 | self.docker._existing_containers = [ 310 | Bunch( 311 | status="running", 312 | network="the-network", 313 | name="{}-testing-123".format(service.name), 314 | ) 315 | ] 316 | agent.start_service() 317 | agent.join() 318 | assert service.ping_count == 0 319 | assert not service.init_called 320 | assert not service.pre_start_called 321 | 322 | def test_yes_ping_no_init_if_started(self): 323 | service = FakeService() 324 | fake_context = FakeRunningContext() 325 | agent = ServiceAgent(service, DEFAULT_OPTIONS, fake_context) 326 | self.docker._existing_containers = [ 327 | Bunch( 328 | status="exited", 329 | network="the-network", 330 | id="longass-container-id", 331 | image=Bunch(tags=[service.image]), 332 | attrs={"Config": {"Env": []}}, 333 | name="{}-testing-123".format(service.name), 334 | ) 335 | ] 336 | agent.start_service() 337 | agent.join() 338 | assert service.ping_count == 1 339 | assert not service.init_called 340 | assert self.docker._containers_ran == ["longass-container-id"] 341 | 342 | @patch("miniboss.service_agent.time") 343 | def test_repeat_ping_and_timeout(self, mock_time): 344 | mock_time.monotonic.side_effect = [0, 0.2, 0.6, 0.8, 1] 345 | fake_context = FakeRunningContext() 346 | fake_service = FakeService(fail_ping=True) 347 | agent = ServiceAgent(fake_service, DEFAULT_OPTIONS, fake_context) 348 | agent.start_service() 349 | agent.join() 350 | assert fake_service.ping_count == 3 351 | assert mock_time.sleep.call_count == 3 352 | assert agent.status == AgentStatus.FAILED 353 | assert len(fake_context.failed_services) == 1 354 | assert fake_context.failed_services[0] is fake_service 355 | 356 | def test_service_failed_on_failed_ping(self): 357 | fake_context = FakeRunningContext() 358 | fake_service = FakeService(fail_ping=True) 359 | # Using options with low timeout so that test doesn't hang 360 | options = Options( 361 | network=Network(name="the-network", id="the-network-id"), 362 | timeout=0.1, 363 | remove=True, 364 | run_dir="/etc", 365 | build=[], 366 | ) 367 | agent = ServiceAgent(fake_service, options, fake_context) 368 | agent.start_service() 369 | agent.join() 370 | assert fake_service.ping_count > 0 371 | assert fake_context.started_services == [] 372 | assert len(fake_context.failed_services) == 1 373 | assert fake_context.failed_services[0].name == "service1" 374 | 375 | def test_stop_remove_container_on_failed(self): 376 | fake_context = FakeRunningContext() 377 | name = "aservice" 378 | container = FakeContainer( 379 | name="{}-testing-5678".format(name), network="the-network", status="running" 380 | ) 381 | _context = self 382 | 383 | class CrazyFakeService(FakeService): 384 | def ping(self): 385 | _context.docker._existing_containers = [container] 386 | raise ValueError("Blah") 387 | 388 | options = Options( 389 | network=Network(name="the-network", id="the-network-id"), 390 | timeout=0.01, 391 | remove=True, 392 | run_dir="/etc", 393 | build=[], 394 | ) 395 | agent = ServiceAgent(CrazyFakeService(name=name), options, fake_context) 396 | agent.start_service() 397 | agent.join() 398 | assert container.stopped 399 | assert container.removed_at is not None 400 | # This is 0 because the service wasn't stopped by the user 401 | assert len(fake_context.stopped_services) == 0 402 | 403 | def test_call_collection_failed_on_error(self): 404 | fake_context = FakeRunningContext() 405 | fake_service = FakeService(exception_at_init=ValueError) 406 | agent = ServiceAgent(fake_service, DEFAULT_OPTIONS, fake_context) 407 | agent.start_service() 408 | agent.join() 409 | assert fake_service.ping_count > 0 410 | assert fake_context.started_services == [] 411 | assert len(fake_context.failed_services) == 1 412 | assert fake_context.failed_services[0].name == "service1" 413 | 414 | def test_stop_container_does_not_exist(self): 415 | fake_context = FakeRunningContext() 416 | fake_service = FakeService(exception_at_init=ValueError) 417 | agent = ServiceAgent(fake_service, DEFAULT_OPTIONS, fake_context) 418 | agent.stop_service() 419 | agent.join() 420 | assert agent.status == AgentStatus.STOPPED 421 | 422 | def test_stop_existing_container(self): 423 | fake_context = FakeRunningContext() 424 | fake_service = FakeService(exception_at_init=ValueError) 425 | container = FakeContainer( 426 | name="{}-testing-5678".format(fake_service.name), 427 | network="the-network", 428 | status="running", 429 | ) 430 | self.docker._existing_containers = [container] 431 | agent = ServiceAgent(fake_service, DEFAULT_OPTIONS, fake_context) 432 | agent.stop_service() 433 | agent.join() 434 | assert agent.status == AgentStatus.STOPPED 435 | assert container.stopped 436 | assert len(fake_context.stopped_services) == 1 437 | assert fake_context.stopped_services[0] is fake_service 438 | 439 | @patch("miniboss.service_agent.datetime") 440 | def test_build_image(self, mock_datetime): 441 | now = datetime.now() 442 | mock_datetime.now.return_value = now 443 | fake_service = FakeService(name="myservice") 444 | fake_service.build_from = "the/service/dir" 445 | agent = ServiceAgent(fake_service, DEFAULT_OPTIONS, FakeRunningContext()) 446 | retval = agent.build_image() 447 | assert len(self.docker._images_built) == 1 448 | build_dir, dockerfile, image_tag = self.docker._images_built[0] 449 | assert build_dir == "/etc/the/service/dir" 450 | assert dockerfile == "Dockerfile" 451 | assert image_tag == now.strftime("myservice-%Y-%m-%d-%H%M") 452 | assert retval == image_tag 453 | assert RunCondition.BUILD_IMAGE in agent.run_condition.actions 454 | 455 | def test_build_image_dockerfile(self): 456 | fake_service = FakeService(name="myservice") 457 | fake_service.dockerfile = "Dockerfile.other" 458 | fake_service.build_from = "the/service/dir" 459 | agent = ServiceAgent(fake_service, DEFAULT_OPTIONS, FakeRunningContext()) 460 | agent.build_image() 461 | assert len(self.docker._images_built) == 1 462 | _, dockerfile, _ = self.docker._images_built[0] 463 | assert dockerfile == "Dockerfile.other" 464 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code. 6 | extension-pkg-whitelist= 7 | 8 | # Specify a score threshold to be exceeded before program exits with error. 9 | fail-under=10.0 10 | 11 | # Add files or directories to the blacklist. They should be base names, not 12 | # paths. 13 | ignore=CVS 14 | 15 | # Add files or directories matching the regex patterns to the blacklist. The 16 | # regex matches against base names, not paths. 17 | ignore-patterns= 18 | 19 | # Python code to execute, usually for sys.path manipulation such as 20 | # pygtk.require(). 21 | #init-hook= 22 | 23 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 24 | # number of processors available to use. 25 | jobs=1 26 | 27 | # Control the amount of potential inferred values when inferring a single 28 | # object. This can help the performance when dealing with large functions or 29 | # complex, nested conditions. 30 | limit-inference-results=100 31 | 32 | # List of plugins (as comma separated values of python module names) to load, 33 | # usually to register additional checkers. 34 | load-plugins= 35 | 36 | # Pickle collected data for later comparisons. 37 | persistent=yes 38 | 39 | # When enabled, pylint would attempt to guess common misconfiguration and emit 40 | # user-friendly hints instead of false-positive error messages. 41 | suggestion-mode=yes 42 | 43 | # Allow loading of arbitrary C extensions. Extensions are imported into the 44 | # active Python interpreter and may run arbitrary code. 45 | unsafe-load-any-extension=no 46 | 47 | 48 | [MESSAGES CONTROL] 49 | 50 | # Only show warnings with the listed confidence levels. Leave empty to show 51 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. 52 | confidence= 53 | 54 | # Disable the message, report, category or checker with the given id(s). You 55 | # can either give multiple identifiers separated by comma (,) or put this 56 | # option multiple times (only on the command line, not in the configuration 57 | # file where it should appear only once). You can also use "--disable=all" to 58 | # disable everything first and then reenable specific checks. For example, if 59 | # you want to run only the similarities checker, you can use "--disable=all 60 | # --enable=similarities". If you want to run only the classes checker, but have 61 | # no Warning level messages displayed, use "--disable=all --enable=classes 62 | # --disable=W". 63 | disable=print-statement, 64 | parameter-unpacking, 65 | unpacking-in-except, 66 | old-raise-syntax, 67 | backtick, 68 | long-suffix, 69 | old-ne-operator, 70 | old-octal-literal, 71 | import-star-module-level, 72 | non-ascii-bytes-literal, 73 | raw-checker-failed, 74 | bad-inline-option, 75 | locally-disabled, 76 | file-ignored, 77 | suppressed-message, 78 | useless-suppression, 79 | deprecated-pragma, 80 | use-symbolic-message-instead, 81 | apply-builtin, 82 | basestring-builtin, 83 | buffer-builtin, 84 | cmp-builtin, 85 | coerce-builtin, 86 | execfile-builtin, 87 | file-builtin, 88 | long-builtin, 89 | raw_input-builtin, 90 | reduce-builtin, 91 | standarderror-builtin, 92 | unicode-builtin, 93 | xrange-builtin, 94 | coerce-method, 95 | delslice-method, 96 | getslice-method, 97 | setslice-method, 98 | no-absolute-import, 99 | old-division, 100 | dict-iter-method, 101 | dict-view-method, 102 | next-method-called, 103 | metaclass-assignment, 104 | indexing-exception, 105 | raising-string, 106 | reload-builtin, 107 | oct-method, 108 | hex-method, 109 | nonzero-method, 110 | cmp-method, 111 | input-builtin, 112 | round-builtin, 113 | intern-builtin, 114 | unichr-builtin, 115 | map-builtin-not-iterating, 116 | zip-builtin-not-iterating, 117 | range-builtin-not-iterating, 118 | filter-builtin-not-iterating, 119 | using-cmp-argument, 120 | eq-without-hash, 121 | div-method, 122 | idiv-method, 123 | rdiv-method, 124 | exception-message-attribute, 125 | invalid-str-codec, 126 | sys-max-int, 127 | bad-python3-import, 128 | deprecated-string-function, 129 | deprecated-str-translate-call, 130 | deprecated-itertools-function, 131 | deprecated-types-field, 132 | next-method-defined, 133 | dict-items-not-iterating, 134 | dict-keys-not-iterating, 135 | dict-values-not-iterating, 136 | deprecated-operator-function, 137 | deprecated-urllib-function, 138 | xreadlines-attribute, 139 | deprecated-sys-function, 140 | exception-escape, 141 | comprehension-escape, 142 | missing-module-docstring, 143 | missing-class-docstring, 144 | missing-function-docstring, 145 | global-statement, 146 | invalid-name, 147 | too-few-public-methods, 148 | too-many-instance-attributes, 149 | # This is disabled because the runtime catches cyclic imports, and pylint is not smart enough 150 | # to figure out the cyclic imports guarded against with typing.TYPE_CHECKING 151 | cyclic-import, 152 | # This is temporary 153 | protected-access 154 | 155 | # Enable the message, report, category or checker with the given id(s). You can 156 | # either give multiple identifier separated by comma (,) or put this option 157 | # multiple time (only on the command line, not in the configuration file where 158 | # it should appear only once). See also the "--disable" option for examples. 159 | enable=c-extension-no-member 160 | 161 | 162 | [REPORTS] 163 | 164 | # Python expression which should return a score less than or equal to 10. You 165 | # have access to the variables 'error', 'warning', 'refactor', and 'convention' 166 | # which contain the number of messages in each category, as well as 'statement' 167 | # which is the total number of statements analyzed. This score is used by the 168 | # global evaluation report (RP0004). 169 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 170 | 171 | # Template used to display messages. This is a python new-style format string 172 | # used to format the message information. See doc for all details. 173 | #msg-template= 174 | 175 | # Set the output format. Available formats are text, parseable, colorized, json 176 | # and msvs (visual studio). You can also give a reporter class, e.g. 177 | # mypackage.mymodule.MyReporterClass. 178 | output-format=text 179 | 180 | # Tells whether to display a full report or only the messages. 181 | reports=no 182 | 183 | # Activate the evaluation score. 184 | score=yes 185 | 186 | 187 | [REFACTORING] 188 | 189 | # Maximum number of nested blocks for function / method body 190 | max-nested-blocks=5 191 | 192 | # Complete name of functions that never returns. When checking for 193 | # inconsistent-return-statements if a never returning function is called then 194 | # it will be considered as an explicit return statement and no message will be 195 | # printed. 196 | never-returning-functions=sys.exit 197 | 198 | 199 | [MISCELLANEOUS] 200 | 201 | # List of note tags to take in consideration, separated by a comma. 202 | notes=FIXME, 203 | XXX, 204 | TODO 205 | 206 | # Regular expression of note tags to take in consideration. 207 | #notes-rgx= 208 | 209 | 210 | [LOGGING] 211 | 212 | # The type of string formatting that logging methods do. `old` means using % 213 | # formatting, `new` is for `{}` formatting. 214 | logging-format-style=old 215 | 216 | # Logging modules to check that the string format arguments are in logging 217 | # function parameter format. 218 | logging-modules=logging 219 | 220 | 221 | [SPELLING] 222 | 223 | # Limits count of emitted suggestions for spelling mistakes. 224 | max-spelling-suggestions=4 225 | 226 | # Spelling dictionary name. Available dictionaries: none. To make it work, 227 | # install the python-enchant package. 228 | spelling-dict= 229 | 230 | # List of comma separated words that should not be checked. 231 | spelling-ignore-words= 232 | 233 | # A path to a file that contains the private dictionary; one word per line. 234 | spelling-private-dict-file= 235 | 236 | # Tells whether to store unknown words to the private dictionary (see the 237 | # --spelling-private-dict-file option) instead of raising a message. 238 | spelling-store-unknown-words=no 239 | 240 | 241 | [TYPECHECK] 242 | 243 | # List of decorators that produce context managers, such as 244 | # contextlib.contextmanager. Add to this list to register other decorators that 245 | # produce valid context managers. 246 | contextmanager-decorators=contextlib.contextmanager 247 | 248 | # List of members which are set dynamically and missed by pylint inference 249 | # system, and so shouldn't trigger E1101 when accessed. Python regular 250 | # expressions are accepted. 251 | generated-members= 252 | 253 | # Tells whether missing members accessed in mixin class should be ignored. A 254 | # mixin class is detected if its name ends with "mixin" (case insensitive). 255 | ignore-mixin-members=yes 256 | 257 | # Tells whether to warn about missing members when the owner of the attribute 258 | # is inferred to be None. 259 | ignore-none=yes 260 | 261 | # This flag controls whether pylint should warn about no-member and similar 262 | # checks whenever an opaque object is returned when inferring. The inference 263 | # can return multiple potential results while evaluating a Python object, but 264 | # some branches might not be evaluated, which results in partial inference. In 265 | # that case, it might be useful to still emit no-member and other checks for 266 | # the rest of the inferred objects. 267 | ignore-on-opaque-inference=yes 268 | 269 | # List of class names for which member attributes should not be checked (useful 270 | # for classes with dynamically set attributes). This supports the use of 271 | # qualified names. 272 | ignored-classes=optparse.Values,thread._local,_thread._local 273 | 274 | # List of module names for which member attributes should not be checked 275 | # (useful for modules/projects where namespaces are manipulated during runtime 276 | # and thus existing member attributes cannot be deduced by static analysis). It 277 | # supports qualified module names, as well as Unix pattern matching. 278 | ignored-modules= 279 | 280 | # Show a hint with possible names when a member name was not found. The aspect 281 | # of finding the hint is based on edit distance. 282 | missing-member-hint=yes 283 | 284 | # The minimum edit distance a name should have in order to be considered a 285 | # similar match for a missing member name. 286 | missing-member-hint-distance=1 287 | 288 | # The total number of similar names that should be taken in consideration when 289 | # showing a hint for a missing member. 290 | missing-member-max-choices=1 291 | 292 | # List of decorators that change the signature of a decorated function. 293 | signature-mutators= 294 | 295 | 296 | [SIMILARITIES] 297 | 298 | # Ignore comments when computing similarities. 299 | ignore-comments=yes 300 | 301 | # Ignore docstrings when computing similarities. 302 | ignore-docstrings=yes 303 | 304 | # Ignore imports when computing similarities. 305 | ignore-imports=no 306 | 307 | # Minimum lines number of a similarity. 308 | min-similarity-lines=4 309 | 310 | 311 | [STRING] 312 | 313 | # This flag controls whether inconsistent-quotes generates a warning when the 314 | # character used as a quote delimiter is used inconsistently within a module. 315 | check-quote-consistency=no 316 | 317 | # This flag controls whether the implicit-str-concat should generate a warning 318 | # on implicit string concatenation in sequences defined over several lines. 319 | check-str-concat-over-line-jumps=no 320 | 321 | 322 | [VARIABLES] 323 | 324 | # List of additional names supposed to be defined in builtins. Remember that 325 | # you should avoid defining new builtins when possible. 326 | additional-builtins= 327 | 328 | # Tells whether unused global variables should be treated as a violation. 329 | allow-global-unused-variables=yes 330 | 331 | # List of strings which can identify a callback function by name. A callback 332 | # name must start or end with one of those strings. 333 | callbacks=cb_, 334 | _cb 335 | 336 | # A regular expression matching the name of dummy variables (i.e. expected to 337 | # not be used). 338 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 339 | 340 | # Argument names that match this expression will be ignored. Default to name 341 | # with leading underscore. 342 | ignored-argument-names=_.*|^ignored_|^unused_ 343 | 344 | # Tells whether we should check for unused import in __init__ files. 345 | init-import=no 346 | 347 | # List of qualified module names which can have objects that can redefine 348 | # builtins. 349 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 350 | 351 | 352 | [FORMAT] 353 | 354 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 355 | expected-line-ending-format= 356 | 357 | # Regexp for a line that is allowed to be longer than the limit. 358 | ignore-long-lines=^\s*(# )??$ 359 | 360 | # Number of spaces of indent required inside a hanging or continued line. 361 | indent-after-paren=4 362 | 363 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 364 | # tab). 365 | indent-string=' ' 366 | 367 | # Maximum number of characters on a single line. 368 | max-line-length=100 369 | 370 | # Maximum number of lines in a module. 371 | max-module-lines=1000 372 | 373 | # Allow the body of a class to be on the same line as the declaration if body 374 | # contains single statement. 375 | single-line-class-stmt=no 376 | 377 | # Allow the body of an if to be on the same line as the test if there is no 378 | # else. 379 | single-line-if-stmt=no 380 | 381 | 382 | [BASIC] 383 | 384 | # Naming style matching correct argument names. 385 | argument-naming-style=snake_case 386 | 387 | # Regular expression matching correct argument names. Overrides argument- 388 | # naming-style. 389 | #argument-rgx= 390 | 391 | # Naming style matching correct attribute names. 392 | attr-naming-style=snake_case 393 | 394 | # Regular expression matching correct attribute names. Overrides attr-naming- 395 | # style. 396 | #attr-rgx= 397 | 398 | # Bad variable names which should always be refused, separated by a comma. 399 | bad-names=foo, 400 | bar, 401 | baz, 402 | toto, 403 | tutu, 404 | tata 405 | 406 | # Bad variable names regexes, separated by a comma. If names match any regex, 407 | # they will always be refused 408 | bad-names-rgxs= 409 | 410 | # Naming style matching correct class attribute names. 411 | class-attribute-naming-style=any 412 | 413 | # Regular expression matching correct class attribute names. Overrides class- 414 | # attribute-naming-style. 415 | #class-attribute-rgx= 416 | 417 | # Naming style matching correct class names. 418 | class-naming-style=PascalCase 419 | 420 | # Regular expression matching correct class names. Overrides class-naming- 421 | # style. 422 | #class-rgx= 423 | 424 | # Naming style matching correct constant names. 425 | const-naming-style=UPPER_CASE 426 | 427 | # Regular expression matching correct constant names. Overrides const-naming- 428 | # style. 429 | #const-rgx= 430 | 431 | # Minimum line length for functions/classes that require docstrings, shorter 432 | # ones are exempt. 433 | docstring-min-length=-1 434 | 435 | # Naming style matching correct function names. 436 | function-naming-style=snake_case 437 | 438 | # Regular expression matching correct function names. Overrides function- 439 | # naming-style. 440 | #function-rgx= 441 | 442 | # Good variable names which should always be accepted, separated by a comma. 443 | good-names=i, 444 | j, 445 | k, 446 | ex, 447 | Run, 448 | _ 449 | 450 | # Good variable names regexes, separated by a comma. If names match any regex, 451 | # they will always be accepted 452 | good-names-rgxs= 453 | 454 | # Include a hint for the correct naming format with invalid-name. 455 | include-naming-hint=no 456 | 457 | # Naming style matching correct inline iteration names. 458 | inlinevar-naming-style=any 459 | 460 | # Regular expression matching correct inline iteration names. Overrides 461 | # inlinevar-naming-style. 462 | #inlinevar-rgx= 463 | 464 | # Naming style matching correct method names. 465 | method-naming-style=snake_case 466 | 467 | # Regular expression matching correct method names. Overrides method-naming- 468 | # style. 469 | #method-rgx= 470 | 471 | # Naming style matching correct module names. 472 | module-naming-style=snake_case 473 | 474 | # Regular expression matching correct module names. Overrides module-naming- 475 | # style. 476 | #module-rgx= 477 | 478 | # Colon-delimited sets of names that determine each other's naming style when 479 | # the name regexes allow several styles. 480 | name-group= 481 | 482 | # Regular expression which should only match function or class names that do 483 | # not require a docstring. 484 | no-docstring-rgx=^_ 485 | 486 | # List of decorators that produce properties, such as abc.abstractproperty. Add 487 | # to this list to register other decorators that produce valid properties. 488 | # These decorators are taken in consideration only for invalid-name. 489 | property-classes=abc.abstractproperty 490 | 491 | # Naming style matching correct variable names. 492 | variable-naming-style=snake_case 493 | 494 | # Regular expression matching correct variable names. Overrides variable- 495 | # naming-style. 496 | #variable-rgx= 497 | 498 | 499 | [CLASSES] 500 | 501 | # List of method names used to declare (i.e. assign) instance attributes. 502 | defining-attr-methods=__init__, 503 | __new__, 504 | setUp, 505 | __post_init__ 506 | 507 | # List of member names, which should be excluded from the protected access 508 | # warning. 509 | exclude-protected=_asdict, 510 | _fields, 511 | _replace, 512 | _source, 513 | _make 514 | 515 | # List of valid names for the first argument in a class method. 516 | valid-classmethod-first-arg=cls 517 | 518 | # List of valid names for the first argument in a metaclass class method. 519 | valid-metaclass-classmethod-first-arg=cls 520 | 521 | 522 | [DESIGN] 523 | 524 | # Maximum number of arguments for function / method. 525 | max-args=5 526 | 527 | # Maximum number of attributes for a class (see R0902). 528 | max-attributes=7 529 | 530 | # Maximum number of boolean expressions in an if statement (see R0916). 531 | max-bool-expr=5 532 | 533 | # Maximum number of branch for function / method body. 534 | max-branches=12 535 | 536 | # Maximum number of locals for function / method body. 537 | max-locals=15 538 | 539 | # Maximum number of parents for a class (see R0901). 540 | max-parents=7 541 | 542 | # Maximum number of public methods for a class (see R0904). 543 | max-public-methods=20 544 | 545 | # Maximum number of return / yield for function / method body. 546 | max-returns=6 547 | 548 | # Maximum number of statements in function / method body. 549 | max-statements=50 550 | 551 | # Minimum number of public methods for a class (see R0903). 552 | min-public-methods=2 553 | 554 | 555 | [IMPORTS] 556 | 557 | # List of modules that can be imported at any level, not just the top level 558 | # one. 559 | allow-any-import-level= 560 | 561 | # Allow wildcard imports from modules that define __all__. 562 | allow-wildcard-with-all=no 563 | 564 | # Analyse import fallback blocks. This can be used to support both Python 2 and 565 | # 3 compatible code, which means that the block might have code that exists 566 | # only in one or another interpreter, leading to false positives when analysed. 567 | analyse-fallback-blocks=no 568 | 569 | # Deprecated modules which should not be used, separated by a comma. 570 | deprecated-modules=optparse,tkinter.tix 571 | 572 | # Create a graph of external dependencies in the given file (report RP0402 must 573 | # not be disabled). 574 | ext-import-graph= 575 | 576 | # Create a graph of every (i.e. internal and external) dependencies in the 577 | # given file (report RP0402 must not be disabled). 578 | import-graph= 579 | 580 | # Create a graph of internal dependencies in the given file (report RP0402 must 581 | # not be disabled). 582 | int-import-graph= 583 | 584 | # Force import order to recognize a module as part of the standard 585 | # compatibility libraries. 586 | known-standard-library= 587 | 588 | # Force import order to recognize a module as part of a third party library. 589 | known-third-party=enchant 590 | 591 | # Couples of modules and preferred modules, separated by a comma. 592 | preferred-modules= 593 | 594 | 595 | [EXCEPTIONS] 596 | 597 | # Exceptions that will emit a warning when being caught. Defaults to 598 | # "BaseException, Exception". 599 | overgeneral-exceptions=BaseException, 600 | Exception 601 | -------------------------------------------------------------------------------- /tests/unit/test_services.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import pathlib 4 | import shutil 5 | import tempfile 6 | import time 7 | import unittest 8 | from types import SimpleNamespace as Bunch 9 | from unittest.mock import patch 10 | 11 | import attr 12 | import pytest 13 | from common import DEFAULT_OPTIONS, FakeContainer, FakeDocker 14 | from slugify import slugify 15 | 16 | from miniboss import Context, exceptions, service_agent, services, types 17 | from miniboss.service_agent import ServiceAgent 18 | from miniboss.services import ( 19 | Service, 20 | ServiceCollection, 21 | ServiceDefinitionError, 22 | ServiceLoadError, 23 | connect_services, 24 | ) 25 | from miniboss.types import Network, Options 26 | 27 | 28 | class ServiceDefinitionTests(unittest.TestCase): 29 | def test_missing_name(self): 30 | with pytest.raises(ServiceDefinitionError): 31 | 32 | class NewService(Service): 33 | pass 34 | 35 | with pytest.raises(ServiceDefinitionError): 36 | 37 | class NewService(Service): 38 | name = "yes" 39 | 40 | def test_missing_image(self): 41 | with pytest.raises(ServiceDefinitionError): 42 | 43 | class NewService(Service): 44 | name = "yes" 45 | 46 | with pytest.raises(ServiceDefinitionError): 47 | 48 | class NewService(Service): 49 | name = "yes" 50 | image = 34.56 51 | 52 | def test_invalid_field_types(self): 53 | with pytest.raises(ServiceDefinitionError): 54 | 55 | class NewService(Service): 56 | name = "yes" 57 | image = "yes" 58 | ports = "no" 59 | 60 | with pytest.raises(ServiceDefinitionError): 61 | 62 | class NewService(Service): 63 | name = "yes" 64 | image = "yes" 65 | env = "no" 66 | 67 | with pytest.raises(ServiceDefinitionError): 68 | 69 | class NewService(Service): 70 | name = "yes" 71 | image = "yes" 72 | env = {} 73 | always_start_new = 123 74 | 75 | def test_invalid_signal_name(self): 76 | with pytest.raises(ServiceDefinitionError): 77 | 78 | class NewService(Service): 79 | name = "yes" 80 | image = "yes" 81 | env = {} 82 | stop_signal = "HELLO" 83 | 84 | def test_hashable(self): 85 | class NewService(Service): 86 | name = "service_one" 87 | image = "notused" 88 | 89 | service = NewService() 90 | a_dict = {service: "one"} 91 | assert service == NewService() 92 | assert a_dict[NewService()] == "one" 93 | 94 | def test_invalid_build_from(self): 95 | with pytest.raises(ServiceDefinitionError): 96 | 97 | class NewService(Service): 98 | name = "yes" 99 | image = "yes" 100 | env = {} 101 | build_from = 123 102 | 103 | def test_invalid_dockerfile(self): 104 | with pytest.raises(ServiceDefinitionError): 105 | 106 | class NewService(Service): 107 | name = "yes" 108 | image = "yes" 109 | env = {} 110 | dockerfile = 567 111 | 112 | def test_volume_spec(self): 113 | with pytest.raises(ServiceDefinitionError): 114 | 115 | class NewService(Service): 116 | name = "yes" 117 | image = "yes" 118 | volumes = "Hello" 119 | 120 | with pytest.raises(ServiceDefinitionError): 121 | 122 | class NewService(Service): 123 | name = "yes" 124 | image = "yes" 125 | volumes = ["vol1", 123] 126 | 127 | with pytest.raises(ServiceDefinitionError): 128 | 129 | class NewService(Service): 130 | name = "yes" 131 | image = "yes" 132 | volumes = {"vol1": 123} 133 | 134 | with pytest.raises(ServiceDefinitionError): 135 | 136 | class NewService(Service): 137 | name = "yes" 138 | image = "yes" 139 | volumes = {"vol1": {"key": "value"}} 140 | 141 | with pytest.raises(ServiceDefinitionError): 142 | 143 | class NewService(Service): 144 | name = "yes" 145 | image = "yes" 146 | volumes = {"vol1": {"bind": 12345}} 147 | 148 | def test_volume_def_to_binds(self): 149 | class NewService(Service): 150 | name = "yes" 151 | image = "yes" 152 | volumes = {"/home/user/temp": {"bind": "/mnt/vol1", "mode": "ro"}} 153 | 154 | assert NewService().volume_def_to_binds() == ["/mnt/vol1"] 155 | 156 | class NewService(Service): 157 | name = "yes" 158 | image = "yes" 159 | volumes = ["/tmp/dir1:/mnt/vol1", "/tmp/dir2:/mnt/vol2:ro"] 160 | 161 | assert NewService().volume_def_to_binds() == ["/mnt/vol1", "/mnt/vol2"] 162 | 163 | def test_invalid_entrypoint(self): 164 | with pytest.raises(ServiceDefinitionError): 165 | 166 | class NewService(Service): 167 | name = "yes" 168 | image = "yes" 169 | entrypoint = 10 170 | 171 | with pytest.raises(ServiceDefinitionError): 172 | 173 | class NewService(Service): 174 | name = "yes" 175 | image = "yes" 176 | entrypoint = ["ls", 10] 177 | 178 | class NewService(Service): 179 | name = "yes" 180 | image = "yes" 181 | entrypoint = ["ls", "-la"] 182 | 183 | def test_invalid_cmd(self): 184 | with pytest.raises(ServiceDefinitionError): 185 | 186 | class NewService(Service): 187 | name = "yes" 188 | image = "yes" 189 | cmd = 10 190 | 191 | with pytest.raises(ServiceDefinitionError): 192 | 193 | class NewService(Service): 194 | name = "yes" 195 | image = "yes" 196 | cmd = ["ls", 10] 197 | 198 | class NewService(Service): 199 | name = "yes" 200 | image = "yes" 201 | cmd = ["ls", "-la"] 202 | 203 | def test_invalid_user(self): 204 | with pytest.raises(ServiceDefinitionError): 205 | 206 | class NewService(Service): 207 | name = "yes" 208 | image = "yes" 209 | user = 10 210 | 211 | class NewService(Service): 212 | name = "yes" 213 | image = "yes" 214 | user = "auser" 215 | 216 | 217 | class ConnectServicesTests(unittest.TestCase): 218 | def test_raise_exception_on_same_name(self): 219 | services = [ 220 | Bunch(name="hello", image="hello"), 221 | Bunch(name="hello", image="goodbye"), 222 | ] 223 | with pytest.raises(ServiceLoadError): 224 | connect_services(services) 225 | 226 | def test_mix_service_and_name(self): 227 | service_one = Bunch(name="service_one", image="hello", dependencies=[]) 228 | services = [ 229 | service_one, 230 | Bunch(name="service_two", image="hello", dependencies=[service_one]), 231 | Bunch( 232 | name="goodbye", 233 | image="goodbye", 234 | dependencies=[service_one, "service_two"], 235 | ), 236 | ] 237 | by_name = connect_services(services) 238 | assert len(by_name) == 3 239 | assert "goodbye" in by_name 240 | assert len(by_name["goodbye"].dependencies) == 2 241 | 242 | def test_exception_on_invalid_dependency(self): 243 | services = [ 244 | Bunch(name="hello", image="hello", dependencies=[]), 245 | Bunch(name="goodbye", image="goodbye", dependencies=["not_hello"]), 246 | ] 247 | with pytest.raises(ServiceLoadError): 248 | connect_services(services) 249 | 250 | def test_all_good(self): 251 | services = [ 252 | Bunch(name="hello", image="hello", dependencies=[]), 253 | Bunch(name="goodbye", image="goodbye", dependencies=["hello"]), 254 | Bunch( 255 | name="howareyou", image="howareyou", dependencies=["hello", "goodbye"] 256 | ), 257 | ] 258 | by_name = connect_services(services) 259 | assert len(by_name) == 3 260 | hello = by_name["hello"] 261 | assert hello.dependencies == [] 262 | assert len(hello._dependants) == 2 263 | assert by_name["goodbye"] in hello._dependants 264 | assert by_name["howareyou"] in hello._dependants 265 | howareyou = by_name["howareyou"] 266 | assert len(howareyou.dependencies) == 2 267 | assert hello in howareyou.dependencies 268 | assert by_name["goodbye"] in howareyou.dependencies 269 | assert howareyou._dependants == [] 270 | 271 | 272 | class ServiceCollectionTests(unittest.TestCase): 273 | def setUp(self): 274 | self.docker = FakeDocker.Instance = FakeDocker( 275 | {"the-network": "the-network-id"} 276 | ) 277 | services.DockerClient = self.docker 278 | service_agent.DockerClient = self.docker 279 | types.set_group_name("testing") 280 | 281 | def tearDown(self): 282 | types._unset_group_name() 283 | 284 | def test_raise_exception_on_no_services(self): 285 | collection = ServiceCollection() 286 | 287 | class NewServiceBase(Service): 288 | name = "not used" 289 | image = "not used" 290 | 291 | collection._base_class = NewServiceBase 292 | with pytest.raises(ServiceLoadError): 293 | collection.load_definitions() 294 | 295 | def test_raise_exception_on_circular_dependency(self): 296 | collection = ServiceCollection() 297 | 298 | class NewServiceBase(Service): 299 | name = "not used" 300 | image = "not used" 301 | 302 | collection._base_class = NewServiceBase 303 | 304 | class ServiceOne(NewServiceBase): 305 | name = "hello" 306 | image = "hello" 307 | dependencies = ["howareyou"] 308 | 309 | class ServiceTwo(NewServiceBase): 310 | name = "goodbye" 311 | image = "hello" 312 | dependencies = ["hello"] 313 | 314 | class ServiceThree(NewServiceBase): 315 | name = "howareyou" 316 | image = "hello" 317 | dependencies = ["goodbye"] 318 | 319 | with pytest.raises(ServiceLoadError): 320 | collection.load_definitions() 321 | 322 | def test_load_services(self): 323 | collection = ServiceCollection() 324 | 325 | class NewServiceBase(Service): 326 | name = "not used" 327 | image = "not used" 328 | 329 | collection._base_class = NewServiceBase 330 | 331 | class ServiceOne(NewServiceBase): 332 | name = "hello" 333 | image = "hello" 334 | dependencies = ["howareyou"] 335 | 336 | class ServiceTwo(NewServiceBase): 337 | name = "goodbye" 338 | image = "hello" 339 | dependencies = ["hello"] 340 | 341 | class ServiceThree(NewServiceBase): 342 | name = "howareyou" 343 | image = "hello" 344 | 345 | collection.load_definitions() 346 | assert len(collection) == 3 347 | 348 | def test_exclude_for_start(self): 349 | collection = ServiceCollection() 350 | 351 | class NewServiceBase(Service): 352 | name = "not used" 353 | image = "not used" 354 | 355 | collection._base_class = NewServiceBase 356 | 357 | class ServiceOne(NewServiceBase): 358 | name = "hello" 359 | image = "hello" 360 | dependencies = ["howareyou"] 361 | 362 | class ServiceTwo(NewServiceBase): 363 | name = "goodbye" 364 | image = "hello" 365 | dependencies = ["hello"] 366 | 367 | class ServiceThree(NewServiceBase): 368 | name = "howareyou" 369 | image = "hello" 370 | 371 | collection.load_definitions() 372 | collection.exclude_for_start(["goodbye"]) 373 | assert len(collection) == 2 374 | 375 | def test_error_on_start_dependency_excluded(self): 376 | collection = ServiceCollection() 377 | 378 | class NewServiceBase(Service): 379 | name = "not used" 380 | image = "not used" 381 | 382 | collection._base_class = NewServiceBase 383 | 384 | class ServiceOne(NewServiceBase): 385 | name = "hello" 386 | image = "hello" 387 | dependencies = ["howareyou"] 388 | 389 | class ServiceTwo(NewServiceBase): 390 | name = "goodbye" 391 | image = "hello" 392 | dependencies = ["hello"] 393 | 394 | class ServiceThree(NewServiceBase): 395 | name = "howareyou" 396 | image = "hello" 397 | 398 | collection.load_definitions() 399 | with pytest.raises(ServiceLoadError): 400 | collection.exclude_for_start(["hello"]) 401 | 402 | def test_start_dependency_and_dependant_excluded(self): 403 | collection = ServiceCollection() 404 | 405 | class NewServiceBase(Service): 406 | name = "not used" 407 | image = "not used" 408 | 409 | collection._base_class = NewServiceBase 410 | 411 | class ServiceOne(NewServiceBase): 412 | name = "hello" 413 | image = "hello" 414 | dependencies = ["howareyou"] 415 | 416 | class ServiceTwo(NewServiceBase): 417 | name = "goodbye" 418 | image = "hello" 419 | dependencies = ["hello"] 420 | 421 | class ServiceThree(NewServiceBase): 422 | name = "howareyou" 423 | image = "hello" 424 | 425 | collection.load_definitions() 426 | # There shouldn't be an exception, since we are excluding both hello and 427 | # goodbye 428 | collection.exclude_for_start(["hello", "goodbye"]) 429 | 430 | def test_error_on_stop_dependency_excluded(self): 431 | collection = ServiceCollection() 432 | 433 | class NewServiceBase(Service): 434 | name = "not used" 435 | image = "not used" 436 | 437 | collection._base_class = NewServiceBase 438 | 439 | class ServiceOne(NewServiceBase): 440 | name = "hello" 441 | image = "hello" 442 | dependencies = ["howareyou"] 443 | 444 | class ServiceTwo(NewServiceBase): 445 | name = "goodbye" 446 | image = "hello" 447 | dependencies = ["hello"] 448 | 449 | class ServiceThree(NewServiceBase): 450 | name = "howareyou" 451 | image = "hello" 452 | 453 | collection.load_definitions() 454 | with pytest.raises(ServiceLoadError): 455 | collection.exclude_for_stop(["goodbye"]) 456 | 457 | def test_stop_dependency_and_dependant_excluded(self): 458 | collection = ServiceCollection() 459 | 460 | class NewServiceBase(Service): 461 | name = "not used" 462 | image = "not used" 463 | 464 | collection._base_class = NewServiceBase 465 | 466 | class ServiceOne(NewServiceBase): 467 | name = "hello" 468 | image = "hello" 469 | dependencies = ["howareyou"] 470 | 471 | class ServiceTwo(NewServiceBase): 472 | name = "goodbye" 473 | image = "hello" 474 | dependencies = ["hello"] 475 | 476 | class ServiceThree(NewServiceBase): 477 | name = "howareyou" 478 | image = "hello" 479 | 480 | collection.load_definitions() 481 | collection.exclude_for_stop(["howareyou", "hello"]) 482 | 483 | def test_populate_dependants(self): 484 | collection = ServiceCollection() 485 | 486 | class NewServiceBase(Service): 487 | name = "not used" 488 | image = "not used" 489 | 490 | collection._base_class = NewServiceBase 491 | 492 | class ServiceOne(NewServiceBase): 493 | name = "hello" 494 | image = "not/used" 495 | dependencies = ["howareyou"] 496 | 497 | class ServiceTwo(NewServiceBase): 498 | name = "goodbye" 499 | image = "not/used" 500 | dependencies = ["hello", "howareyou"] 501 | 502 | class ServiceThree(NewServiceBase): 503 | name = "howareyou" 504 | image = "not/used" 505 | 506 | collection.load_definitions() 507 | assert len(collection.all_by_name) == 3 508 | hello = collection.all_by_name["hello"] 509 | assert len(hello._dependants) == 1 510 | assert hello._dependants[0].name == "goodbye" 511 | howareyou = collection.all_by_name["howareyou"] 512 | assert len(howareyou._dependants) == 2 513 | names = [x.name for x in howareyou._dependants] 514 | assert "hello" in names 515 | assert "goodbye" in names 516 | 517 | def test_start_all(self): 518 | # This test does not fake threading, which is somehow dangerous, but the 519 | # aim is to make sure that the error handling etc. works also when there 520 | # is an exception in the service agent thread, and the 521 | # collection.start_all method does not hang. 522 | collection = ServiceCollection() 523 | 524 | class NewServiceBase(Service): 525 | name = "not used" 526 | image = "not used" 527 | 528 | collection._base_class = NewServiceBase 529 | 530 | class ServiceOne(NewServiceBase): 531 | name = "hello" 532 | image = "hello/image" 533 | dependencies = ["howareyou"] 534 | 535 | class ServiceTwo(NewServiceBase): 536 | name = "goodbye" 537 | image = "goodbye/image" 538 | dependencies = ["hello"] 539 | 540 | class ServiceThree(NewServiceBase): 541 | name = "howareyou" 542 | image = "howareyou/image" 543 | 544 | collection.load_definitions() 545 | retval = collection.start_all(DEFAULT_OPTIONS) 546 | assert set(retval) == {"hello", "goodbye", "howareyou"} 547 | assert len(self.docker._services_started) == 3 548 | # The one without dependencies should have been started first 549 | name_prefix, service, network_name = self.docker._services_started[0] 550 | assert service.image == "howareyou/image" 551 | assert name_prefix == "howareyou-testing" 552 | 553 | def test_start_all_with_build(self): 554 | collection = ServiceCollection() 555 | 556 | class NewServiceBase(Service): 557 | name = "not used" 558 | image = "not used" 559 | 560 | collection._base_class = NewServiceBase 561 | 562 | class ServiceTwo(NewServiceBase): 563 | name = "goodbye" 564 | image = "goodbye/image" 565 | build_from = "goodbye/dir" 566 | dockerfile = "Dockerfile.alt" 567 | 568 | collection.load_definitions() 569 | options = attr.evolve(DEFAULT_OPTIONS, build=["goodbye"]) 570 | retval = collection.start_all(options) 571 | assert len(self.docker._images_built) == 1 572 | build_dir, dockerfile, image_tag = self.docker._images_built[0] 573 | assert build_dir == "/etc/goodbye/dir" 574 | assert dockerfile == "Dockerfile.alt" 575 | assert image_tag.startswith("goodbye-") 576 | service = collection.all_by_name["goodbye"] 577 | assert service.image == image_tag 578 | 579 | def test_start_all_create_network(self): 580 | collection = ServiceCollection() 581 | 582 | class NewServiceBase(Service): 583 | name = "not used" 584 | image = "not used" 585 | 586 | class ServiceTwo(NewServiceBase): 587 | name = "goodbye" 588 | image = "goodbye/image" 589 | 590 | collection._base_class = NewServiceBase 591 | collection.load_definitions() 592 | collection.start_all(DEFAULT_OPTIONS) 593 | assert self.docker._networks_created == ["the-network"] 594 | 595 | def test_stop_on_fail(self): 596 | collection = ServiceCollection() 597 | 598 | class NewServiceBase(Service): 599 | name = "not used" 600 | image = "not used" 601 | 602 | class TheService(NewServiceBase): 603 | name = "howareyou" 604 | image = "howareyou/image" 605 | 606 | def ping(self): 607 | raise ValueError("I failed miserably") 608 | 609 | collection._base_class = NewServiceBase 610 | collection.load_definitions() 611 | started = collection.start_all(DEFAULT_OPTIONS) 612 | assert started == [] 613 | 614 | def test_dont_return_failed_services(self): 615 | collection = ServiceCollection() 616 | 617 | class NewServiceBase(Service): 618 | name = "not used" 619 | image = "not used" 620 | 621 | class TheFirstService(NewServiceBase): 622 | name = "howareyou" 623 | image = "howareyou/image" 624 | 625 | class TheService(NewServiceBase): 626 | name = "imok" 627 | image = "howareyou/image" 628 | dependencies = ["howareyou"] 629 | 630 | def ping(self): 631 | raise ValueError("I failed miserably") 632 | 633 | collection._base_class = NewServiceBase 634 | collection.load_definitions() 635 | started = collection.start_all(DEFAULT_OPTIONS) 636 | assert started == ["howareyou"] 637 | 638 | def test_continue_if_start_failed(self): 639 | """If a service fails, those that don't depend on it should still be started""" 640 | collection = ServiceCollection() 641 | 642 | class NewServiceBase(Service): 643 | name = "not used" 644 | image = "not used" 645 | 646 | class FirstService(NewServiceBase): 647 | name = "first-service" 648 | image = "howareyou/image" 649 | 650 | def ping(self): 651 | raise ValueError("I failed miserably") 652 | 653 | class SecondService(NewServiceBase): 654 | name = "second-service" 655 | image = "howareyou/image" 656 | 657 | def ping(self): 658 | time.sleep(0.5) 659 | return True 660 | 661 | collection._base_class = NewServiceBase 662 | collection.load_definitions() 663 | started = collection.start_all(DEFAULT_OPTIONS) 664 | assert started == ["second-service"] 665 | 666 | def test_stop_all_remove_false(self): 667 | container1 = FakeContainer( 668 | name="service1-testing-1234", 669 | stopped=False, 670 | network="the-network", 671 | status="running", 672 | ) 673 | container2 = FakeContainer( 674 | name="service2-testing-5678", 675 | stopped=False, 676 | removed=False, 677 | network="the-network", 678 | status="exited", 679 | ) 680 | self.docker._existing_containers = [container1, container2] 681 | collection = ServiceCollection() 682 | 683 | class NewServiceBase(Service): 684 | name = "not used" 685 | image = "not used" 686 | 687 | class ServiceOne(NewServiceBase): 688 | name = "service1" 689 | image = "howareyou/image" 690 | 691 | class ServiceTwo(NewServiceBase): 692 | name = "service2" 693 | image = "howareyou/image" 694 | 695 | collection._base_class = NewServiceBase 696 | collection.load_definitions() 697 | collection.stop_all(DEFAULT_OPTIONS) 698 | assert container1.stopped 699 | assert container1.timeout == 1 700 | assert not container2.stopped 701 | 702 | def test_stop_without_remove(self): 703 | container1 = FakeContainer( 704 | name="service1-testing-1234", network="the-network", status="running" 705 | ) 706 | container2 = FakeContainer( 707 | name="service2-testing-5678", network="the-network", status="exited" 708 | ) 709 | self.docker._existing_containers = [container1, container2] 710 | collection = ServiceCollection() 711 | 712 | class NewServiceBase(Service): 713 | name = "not used" 714 | image = "not used" 715 | 716 | class ServiceOne(NewServiceBase): 717 | name = "service1" 718 | image = "howareyou/image" 719 | 720 | class ServiceTwo(NewServiceBase): 721 | name = "service2" 722 | image = "howareyou/image" 723 | 724 | collection._base_class = NewServiceBase 725 | collection.load_definitions() 726 | collection.stop_all(DEFAULT_OPTIONS) 727 | assert container1.stopped 728 | assert container1.timeout == 1 729 | assert container1.removed_at is None 730 | assert not container2.stopped 731 | assert self.docker._networks_removed == [] 732 | 733 | def test_stop_with_remove_and_order(self): 734 | container1 = FakeContainer( 735 | name="service1-testing-1234", network="the-network", status="running" 736 | ) 737 | container2 = FakeContainer( 738 | name="service2-testing-5678", network="the-network", status="running" 739 | ) 740 | container3 = FakeContainer( 741 | name="service3-testing-5678", network="the-network", status="running" 742 | ) 743 | self.docker._existing_containers = [container1, container2, container3] 744 | collection = ServiceCollection() 745 | 746 | class NewServiceBase(Service): 747 | name = "not used" 748 | image = "not used" 749 | 750 | class ServiceOne(NewServiceBase): 751 | name = "service1" 752 | image = "howareyou/image" 753 | 754 | class ServiceTwo(NewServiceBase): 755 | name = "service2" 756 | image = "howareyou/image" 757 | dependencies = ["service1"] 758 | 759 | class ServiceThree(NewServiceBase): 760 | name = "service3" 761 | image = "howareyou/image" 762 | dependencies = ["service2"] 763 | 764 | collection._base_class = NewServiceBase 765 | collection.load_definitions() 766 | options = Options( 767 | network=Network(name="the-network", id="the-network-id"), 768 | timeout=50, 769 | remove=True, 770 | run_dir="/etc", 771 | build=[], 772 | ) 773 | collection.stop_all(options) 774 | assert container1.stopped 775 | assert container1.removed_at is not None 776 | assert container2.stopped 777 | assert container2.removed_at is not None 778 | assert container3.stopped 779 | assert container3.removed_at is not None 780 | assert container1.removed_at > container2.removed_at > container3.removed_at 781 | assert self.docker._networks_removed == ["the-network"] 782 | 783 | def test_stop_with_remove_and_exclude(self): 784 | container1 = FakeContainer( 785 | name="service1-testing-1234", network="the-network", status="running" 786 | ) 787 | container2 = FakeContainer( 788 | name="service2-testing-5678", network="the-network", status="running" 789 | ) 790 | self.docker._existing_containers = [container1, container2] 791 | collection = ServiceCollection() 792 | 793 | class NewServiceBase(Service): 794 | name = "not used" 795 | image = "not used" 796 | 797 | class ServiceOne(NewServiceBase): 798 | name = "service1" 799 | image = "howareyou/image" 800 | 801 | class ServiceTwo(NewServiceBase): 802 | name = "service2" 803 | image = "howareyou/image" 804 | 805 | collection._base_class = NewServiceBase 806 | collection.load_definitions() 807 | collection.exclude_for_stop(["service2"]) 808 | options = Options( 809 | network=Network(name="the-network", id="the-network-id"), 810 | timeout=50, 811 | remove=True, 812 | run_dir="/etc", 813 | build=[], 814 | ) 815 | retval = collection.stop_all(options) 816 | assert retval == ["service1"] 817 | assert container1.stopped 818 | assert container1.removed_at is not None 819 | # service2 was excluded 820 | assert not container2.stopped 821 | assert container2.removed_at is None 822 | # If excluded is not empty, network should not be removed 823 | assert self.docker._networks_removed == [] 824 | 825 | def test_update_for_base_service(self): 826 | container1 = FakeContainer( 827 | name="service1-testing-1234", network="the-network", status="running" 828 | ) 829 | container2 = FakeContainer( 830 | name="service2-testing-5678", network="the-network", status="running" 831 | ) 832 | container3 = FakeContainer( 833 | name="service3-testing-5678", network="the-network", status="running" 834 | ) 835 | self.docker._existing_containers = [container1, container2, container3] 836 | collection = ServiceCollection() 837 | 838 | class NewServiceBase(Service): 839 | name = "not used" 840 | image = "not used" 841 | 842 | class ServiceOne(NewServiceBase): 843 | name = "service1" 844 | image = "howareyou/image" 845 | 846 | class ServiceTwo(NewServiceBase): 847 | name = "service2" 848 | image = "howareyou/image" 849 | dependencies = ["service1"] 850 | 851 | class ServiceThree(NewServiceBase): 852 | name = "service3" 853 | image = "howareyou/image" 854 | dependencies = ["service1", "service2"] 855 | 856 | collection._base_class = NewServiceBase 857 | collection.load_definitions() 858 | collection.update_for_base_service("service2") 859 | assert collection.all_by_name == { 860 | "service2": ServiceTwo(), 861 | "service3": ServiceThree(), 862 | } 863 | collection.stop_all(DEFAULT_OPTIONS) 864 | assert not container1.stopped 865 | assert container2.stopped 866 | assert container3.stopped 867 | 868 | def test_check_can_be_built(self): 869 | collection = ServiceCollection() 870 | 871 | class NewServiceBase(Service): 872 | name = "not used" 873 | image = "not used" 874 | 875 | class ServiceOne(NewServiceBase): 876 | name = "service1" 877 | image = "howareyou/image" 878 | 879 | class ServiceTwo(NewServiceBase): 880 | name = "service2" 881 | image = "howareyou/image" 882 | build_from = "the/service/dir" 883 | 884 | collection._base_class = NewServiceBase 885 | collection.load_definitions() 886 | with pytest.raises(ServiceDefinitionError): 887 | collection.check_can_be_built("no-such-service") 888 | with pytest.raises(ServiceDefinitionError): 889 | collection.check_can_be_built("service1") 890 | collection.check_can_be_built("service2") 891 | 892 | 893 | class ServiceCommandTests(unittest.TestCase): 894 | def setUp(self): 895 | class MockServiceCollection: 896 | def load_definitions(self): 897 | pass 898 | 899 | def exclude_for_start(self, exclude): 900 | self.excluded = exclude 901 | 902 | def exclude_for_stop(self, exclude): 903 | self.excluded = exclude 904 | 905 | def start_all(self, options): 906 | self.options = options 907 | return ["one", "two"] 908 | 909 | def stop_all(self, options): 910 | self.options = options 911 | self.stopped = True 912 | return ["one", "two"] 913 | 914 | def reload_service(self, service_name, options): 915 | self.options = options 916 | self.reloaded = service_name 917 | 918 | def check_can_be_built(self, service_name): 919 | self.checked_can_be_built = service_name 920 | 921 | def update_for_base_service(self, service_name): 922 | self.updated_for_base_service = service_name 923 | 924 | self.collection = MockServiceCollection() 925 | services.ServiceCollection = lambda: self.collection 926 | types.set_group_name("test") 927 | Context._reset() 928 | self.workdir = tempfile.mkdtemp() 929 | 930 | def tearDown(self): 931 | types._unset_group_name() 932 | shutil.rmtree(self.workdir) 933 | 934 | def test_update_group_name_on_start(self): 935 | types._unset_group_name() 936 | services.start_services(self.workdir, [], "miniboss", 50) 937 | assert types.group_name == slugify(pathlib.Path(self.workdir).name) 938 | 939 | def test_update_group_name_on_stop(self): 940 | workdir = tempfile.mkdtemp() 941 | types._unset_group_name() 942 | services.stop_services(self.workdir, ["test"], "miniboss", False, 50) 943 | assert types.group_name == slugify(pathlib.Path(self.workdir).name) 944 | 945 | def test_update_group_name_on_reload(self): 946 | workdir = tempfile.mkdtemp() 947 | types._unset_group_name() 948 | services.reload_service(self.workdir, "the-service", "miniboss", False, 50) 949 | assert types.group_name == slugify(pathlib.Path(self.workdir).name) 950 | 951 | def test_start_services_exclude(self): 952 | services.start_services("/tmp", ["blah"], "miniboss", 50) 953 | assert self.collection.excluded == ["blah"] 954 | 955 | def test_start_services_save_context(self): 956 | directory = tempfile.mkdtemp() 957 | Context["key_one"] = "a_value" 958 | Context["key_two"] = "other_value" 959 | services.start_services(directory, [], "miniboss", 50) 960 | with open(os.path.join(directory, ".miniboss-context"), "r") as context_file: 961 | context_data = json.load(context_file) 962 | assert context_data == {"key_one": "a_value", "key_two": "other_value"} 963 | 964 | def test_start_services(self): 965 | services.start_services("/tmp", [], "miniboss", 50) 966 | options = self.collection.options 967 | assert options.network.name == "miniboss" 968 | assert options.network.id == "" 969 | assert options.timeout == 50 970 | assert options.remove == False 971 | assert options.run_dir == "/tmp" 972 | assert options.build == [] 973 | 974 | def test_services_network_name_none(self): 975 | services.start_services("/tmp", [], None, 50) 976 | options = self.collection.options 977 | assert options.network.name == "miniboss-test" 978 | 979 | def test_start_services_hook(self): 980 | sentinel = None 981 | 982 | def hook(services): 983 | nonlocal sentinel 984 | sentinel = services 985 | 986 | services.on_start_services(hook) 987 | services.start_services("/tmp", [], "miniboss", 50) 988 | assert sentinel == ["one", "two"] 989 | 990 | def test_start_services_exception(self): 991 | sentinel = None 992 | 993 | def hook(services): 994 | nonlocal sentinel 995 | sentinel = services 996 | raise ValueError("Hoho") 997 | 998 | services.on_start_services(hook) 999 | services.start_services("/tmp", [], "miniboss", 50) 1000 | assert sentinel == ["one", "two"] 1001 | 1002 | def test_load_context_on_new(self): 1003 | directory = tempfile.mkdtemp() 1004 | with open(os.path.join(directory, ".miniboss-context"), "w") as context_file: 1005 | context_file.write( 1006 | json.dumps({"key_one": "value_one", "key_two": "value_two"}) 1007 | ) 1008 | services.start_services(directory, [], "miniboss", 50) 1009 | assert Context["key_one"] == "value_one" 1010 | assert Context["key_two"] == "value_two" 1011 | 1012 | def test_stop_services(self): 1013 | services.stop_services("/tmp", ["test"], "miniboss", False, 50) 1014 | assert self.collection.options.network.name == "miniboss" 1015 | assert self.collection.options.timeout == 50 1016 | assert self.collection.options.run_dir == "/tmp" 1017 | assert not self.collection.options.remove 1018 | assert self.collection.excluded == ["test"] 1019 | 1020 | def test_start_services_hook(self): 1021 | sentinel = None 1022 | 1023 | def hook(services): 1024 | nonlocal sentinel 1025 | sentinel = services 1026 | 1027 | services.on_start_services(hook) 1028 | services.start_services("/tmp", [], "miniboss", 50) 1029 | assert sentinel == ["one", "two"] 1030 | 1031 | def test_start_services_hook_exception(self): 1032 | sentinel = None 1033 | 1034 | def hook(services): 1035 | nonlocal sentinel 1036 | sentinel = services 1037 | raise ValueError("Hoho") 1038 | 1039 | services.on_start_services(hook) 1040 | services.start_services("/tmp", [], "miniboss", 50) 1041 | assert sentinel == ["one", "two"] 1042 | 1043 | def test_stop_services_network_name_none(self): 1044 | services.stop_services("/tmp", ["test"], None, False, 50) 1045 | assert self.collection.options.network.name == "miniboss-test" 1046 | 1047 | def test_stop_services_hook(self): 1048 | sentinel = None 1049 | 1050 | def hook(services): 1051 | nonlocal sentinel 1052 | sentinel = services 1053 | 1054 | services.on_stop_services(hook) 1055 | services.stop_services("/tmp", ["test"], "miniboss", False, 50) 1056 | assert sentinel == ["one", "two"] 1057 | 1058 | def test_stop_services_hook_exception(self): 1059 | sentinel = None 1060 | 1061 | def hook(services): 1062 | nonlocal sentinel 1063 | sentinel = services 1064 | raise ValueError("Hoho") 1065 | 1066 | services.on_stop_services(hook) 1067 | services.stop_services("/tmp", ["test"], "miniboss", False, 50) 1068 | assert sentinel == ["one", "two"] 1069 | 1070 | def test_stop_services_remove_context(self): 1071 | directory = tempfile.mkdtemp() 1072 | path = pathlib.Path(directory) / ".miniboss-context" 1073 | with open(path, "w") as context_file: 1074 | context_file.write( 1075 | json.dumps({"key_one": "value_one", "key_two": "value_two"}) 1076 | ) 1077 | services.stop_services(directory, [], "miniboss", False, 50) 1078 | assert path.exists() 1079 | services.stop_services(directory, [], "miniboss", True, 50) 1080 | assert not path.exists() 1081 | 1082 | def test_reload_service(self): 1083 | services.reload_service("/tmp", "the-service", "miniboss", False, 50) 1084 | assert self.collection.checked_can_be_built == "the-service" 1085 | assert self.collection.updated_for_base_service == "the-service" 1086 | assert self.collection.options.network.name == "miniboss" 1087 | assert self.collection.options.timeout == 50 1088 | assert self.collection.options.run_dir == "/tmp" 1089 | assert self.collection.options.build == ["the-service"] 1090 | assert not self.collection.options.remove 1091 | 1092 | def test_reload_service_network_name_none(self): 1093 | services.reload_service("/tmp", "the-service", None, False, 50) 1094 | assert self.collection.options.network.name == "miniboss-test" 1095 | 1096 | def test_reload_service_hook(self): 1097 | sentinel = None 1098 | 1099 | def hook(service_name): 1100 | nonlocal sentinel 1101 | sentinel = service_name 1102 | 1103 | services.on_reload_service(hook) 1104 | services.reload_service("/tmp", "the-service", "miniboss", False, 50) 1105 | assert sentinel == "the-service" 1106 | 1107 | def test_stop_services_hook_exception(self): 1108 | sentinel = None 1109 | 1110 | def hook(services): 1111 | nonlocal sentinel 1112 | sentinel = services 1113 | raise ValueError("Hoho") 1114 | 1115 | services.on_reload_service(hook) 1116 | services.reload_service("/tmp", "the-service", "miniboss", False, 50) 1117 | assert sentinel == "the-service" 1118 | 1119 | def test_reload_service_save_and_load_context(self): 1120 | directory = tempfile.mkdtemp() 1121 | path = pathlib.Path(directory) / ".miniboss-context" 1122 | with open(path, "w") as context_file: 1123 | context_file.write( 1124 | json.dumps({"key_one": "value_one", "key_two": "value_two"}) 1125 | ) 1126 | services.reload_service(directory, "the-service", "miniboss", False, 50) 1127 | assert Context["key_one"] == "value_one" 1128 | assert Context["key_two"] == "value_two" 1129 | assert path.exists() 1130 | --------------------------------------------------------------------------------