├── 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 |
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 |
--------------------------------------------------------------------------------