Browse Source

Merge branch 'enough-compose' into 'master'

enough: refactor for docker-compose instead of swarm

See merge request main/infrastructure!93
keep-around/7a0fe72f2747bcda7c8a4f2c9650c9da302ec1e1
singuliere 2 years ago
parent
commit
7a0fe72f27
  1. 5
      enough/common/data/base.dockerfile
  2. 33
      enough/common/data/docker-compose.yml
  3. 213
      enough/common/docker.py
  4. 11
      enough/configuration.py
  5. 9
      tests/conftest.py
  6. 16
      tests/enough/common/data/common/data/docker-compose-fail.yml
  7. 15
      tests/enough/common/data/common/data/docker-compose.yml
  8. 123
      tests/enough/common/test_common_docker.py
  9. 8
      tests/enough/internal/test_internal_docker.py
  10. 6
      tox.ini

5
enough/common/data/base.dockerfile

@ -1,8 +1,11 @@
FROM debian:buster
RUN apt-get update && \
apt-get install --quiet -y curl virtualenv python3 gcc libffi-dev libssl-dev python3-dev make
apt-get install --quiet -y curl virtualenv python3 gcc libffi-dev libssl-dev python3-dev make \
systemd systemd-sysv
RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh
RUN curl -L "https://github.com/docker/compose/releases/download/1.24.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose && chmod +x /usr/local/bin/docker-compose
WORKDIR /opt
RUN virtualenv --python=python3 venv

33
enough/common/data/docker-compose.yml

@ -0,0 +1,33 @@
version: "3"
services:
enough-{{ this.name }}:
logging:
driver: json-file
entrypoint: /sbin/init
healthcheck:
test: ["CMD", "systemctl", "status"]
interval: 5s
timeout: 3s
retries: 20
command: ''
image: {{ this.get_image_name_with_version('base') }}
working_dir: /opt
tmpfs:
- /tmp
- /run
- /run/lock
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:ro
- {{ this.confdir }}:/root/.enough/default
security_opt:
- seccomp=unconfined
cap_add:
- IPC_LOCK
ports:
- {{ this.get_ports() }}
networks:
- enough-{{ this.name }}
networks:
enough-{{ this.name }}:

213
enough/common/docker.py

@ -1,14 +1,14 @@
from __future__ import print_function
from io import StringIO
import json
import jinja2
import logging
import os
import requests
import sys
import sh
import tempfile
from enough.version import __version__
from enough import configuration
from enough.common.retry import retry, RetryException
log = logging.getLogger(__name__)
@ -18,190 +18,105 @@ class Docker(object):
def __init__(self, name, **kwargs):
self.bake_docker(kwargs.get('docker'))
self.root = os.path.join(os.path.dirname(__file__), '..')
self.root = kwargs.get('root', os.path.join(os.path.dirname(__file__), '..'))
self.name = name
self.session = None
self.local = "DOCKER_HOST" not in os.environ
if not self.is_local():
self.registry = kwargs.get('registry')
self.namespace = kwargs.get('namespace')
self.user = kwargs.get('user')
self.password = kwargs.get('password')
self.confdir = kwargs.get('confdir')
self.port = kwargs.get('port', '8000')
self.retry = kwargs.get('retry', 9)
self.confdir = configuration.get_directory(kwargs.get('domain'))
self.bake_docker_compose(kwargs.get('docker_compose'))
def bake_docker(self, docker):
cmd = docker or 'docker'
self.docker = sh.Command(cmd).bake(_truncate_exc=False)
# .bake(_out=sys.stdout, _err=sys.stderr, )
def get_or_create_password_file(self, filename):
path = os.path.abspath('{confdir}/{filename}'.format(
confdir=self.confdir,
filename=filename))
if not os.path.exists(path):
open(path, 'w').write('enough')
return path
def is_local(self):
return self.local
def bake_docker_compose(self, docker_compose):
content = self.get_compose_content()
if log.getEffectiveLevel() <= logging.DEBUG:
# do not use log.debug() because the output is unreadable on a single line
# and multiline logging is bad practice
print(content)
compose = tempfile.NamedTemporaryFile()
compose.write(content.encode('utf-8'))
compose.flush()
cmd = docker_compose or 'docker-compose'
self.docker_compose = sh.Command(cmd).bake('-f', compose.name, _truncate_exc=False)
self.compose_file = compose # so that it is kept until self is deleted
def get_compose_content(self):
assert 0 # pragma: no cover
f = os.path.join(self.root, 'common/data/docker-compose.yml')
return self.replace_content(open(f).read())
def replace_content(self, content):
return jinja2.Template(content).render(this=self)
def get_ports(self):
return str(self.port) + ':3000' if self.is_local() else '3000'
def deploy(self, args):
self.port = args.get('port')
self.ip_service = args.get('ip_service')
if self.is_local():
self.network_definition = ''
else:
self.network_definition = '{external: {name: external_network}}'
return str(self.port) + ':8000'
self.swarm_init()
def up(self):
self.create_image()
self._deploy()
return self.get_host_port(self.ip_service, self.port)
self._up()
return self.port
def create_image(self):
dockerfile = os.path.join(self.root, 'common/data/base.dockerfile')
return self._create_image('base', '-f', dockerfile, '.')
def _create_image(self, suffix, *args):
name = self.get_image_name(suffix)
name = self.get_image_name_with_version(suffix)
build_args = ['--quiet', '--tag', name]
self.docker.build(build_args + list(args))
self._push_image(suffix)
self.docker.tag(name, self.get_image_name(suffix))
return name
def get_network_peer(self):
@retry(UnboundLocalError, tries=4)
def get_network_id():
networks = self.docker.network.ls('--format', '{{ json . }}', _iter=True)
for network in networks:
network = json.loads(network)
if self.name in network["Name"]:
network_id = network["ID"]
return network_id
network_id = get_network_id()
inspection = StringIO()
@retry(sh.ErrorReturnCode_1, tries=4)
def get_peer():
self.docker.network.inspect('--format', '{{ (index .Peers 0).IP }}',
network_id, _out=inspection)
get_peer()
return inspection.getvalue().strip()
def get_host_port(self, ip_service, port):
if ip_service:
return ip_service
else:
ip_service = self.get_network_peer()
return '{ip_service}:{port}'.format(ip_service=ip_service,
port=port)
def get_repository_name(self, suffix):
def get_image_name(self, suffix):
if suffix:
return self.name + '_' + suffix
else:
return self.name
def get_image_name(self, suffix):
repository = self.get_repository_name(suffix)
name = repository + ':' + str(__version__)
if self.is_local():
return name
else:
return self.registry + '/' + self.namespace + '/' + name
def get_requests_session(self):
if self.session is None:
self.session = requests.Session()
if self.is_local():
self.session.trust_env = False
return self.session
def get_image_name_with_version(self, suffix):
return self.get_image_name(suffix) + ':' + str(__version__)
def rm(self):
self.docker.stack.rm(self.name)
def down(self):
self.docker_compose('down')
def deploy_wait_for_service(self, args):
host_port = self.deploy(args)
def up_wait_for_services(self):
self.up()
try:
self.wait_for_service()
return host_port
self.wait_for_services()
except RetryException:
self.print_stack_logs()
self.print_logs()
raise
def swarm_init(self):
if not self.is_local():
return None
state = self.docker.info('--format', '{{ .Swarm.LocalNodeState }}', _iter=True)
if state.next().strip() == 'inactive':
self.docker.swarm.init()
return True
return False
def _push_image(self, suffix):
if not self.is_local():
self.create_repository(suffix)
name = self.get_full_image_name(suffix)
self.docker.push(name)
def _deploy(self):
content = self.get_compose_content()
if log.getEffectiveLevel() <= logging.DEBUG:
# do not use log.debug() because the output is unreadable on a single line
# and multiline logging is bad practice
print(content)
with tempfile.NamedTemporaryFile() as compose:
compose.write(content.encode('utf-8'))
compose.flush()
self.docker.stack.deploy('-c', compose.name, self.name)
return self.name
def create_repository(self, suffix):
kwargs = {}
if 'DOCKER_CERT_PATH' in os.environ:
kwargs['verify'] = os.environ['DOCKER_CERT_PATH'] + "/ca.pem"
name = self.get_repostiry_name(suffix)
res = self.get_requets_session().post(
'https://' + self.registry + '/api/v0/repositories/' + self.namespace,
auth=(self.user, self.password),
json={'name': name, 'visibility': 'public'},
**kwargs)
log.debug("Creating repository " + self.namespace + "/" + name)
if res.status_code == 400 and res.json()["errors"][0]["code"] == "REPOSITORY_EXISTS":
log.debug("already exists")
elif res.status_code != 201:
res.raise_for_status()
@retry(AssertionError, tries=9)
def wait_for_service(self):
replica = self.docker.service.ls('--filter',
'Name=' + self.name,
'--format', '{{ .Replicas }}',
_iter=True)
nb_up, nb_tot = replica.next().strip().split("/")
assert nb_up == nb_tot
log.info("Docker stack " + self.name + " is up")
def get_stack_logs(self):
def _up(self):
self.docker_compose('up', '-d')
def inspect(self, format):
ids = self.docker_compose('ps', '-q', _iter=True)
results = []
for id in ids:
id = id.strip()
result = StringIO()
self.docker('inspect', f'--format={format}', id, _out=result)
results.append(result.getvalue().strip())
return results
def wait_for_services(self):
@retry(AssertionError, tries=self.retry)
def wait():
results = self.inspect('{{ .State.Health.Status }}')
assert all([x.strip() == 'healthy' for x in results]), str(results)
log.info("enough service " + self.name + " is healthy")
wait()
def get_logs(self):
result = StringIO()
print(u"docker stack ps --no-trunc " + self.name, file=result)
self.docker.stack.ps('--no-trunc', self.name, _out=result)
services = self.docker.stack.ps('--format', '{{ .ID }}', self.name, _iter=True)
for service in services:
id = service.strip()
print("docker service logs " + id, file=result)
self.docker.service.logs('--timestamps', '--details', id, _out=result)
print(f"docker compose logs {self.name}\n", file=result)
self.docker_compose('logs', _out=result)
print(str(self.inspect('{{ json .State.Health }}')))
result.write(str(self.inspect('{{ json .State.Health }}')))
return result.getvalue()
def print_stack_logs(self):
print(self.get_stack_logs(), file=sys.stderr)
def print_logs(self):
print(self.get_logs(), file=sys.stderr)

11
enough/configuration.py

@ -0,0 +1,11 @@
import os
def get_directory(domain):
if domain:
d = os.path.expanduser(f'~/.enough/{domain}')
else:
d = os.path.expanduser(f'~/.enough/default')
if not os.path.exists(d):
os.makedirs(d)
return d

9
tests/conftest.py

@ -58,19 +58,14 @@ class DockerLeftovers(Exception):
@retry(DockerLeftovers, tries=7)
def docker_cleanup(prefix):
leftovers = []
for stack in sh.docker.stack.ls('--format', '{{ .Name }}', _iter=True):
stack = stack.strip()
if stack.startswith(prefix):
sh.docker.stack.rm(stack, _ok_code=[0, 1])
leftovers.append('stack(' + stack + ')')
for container in sh.docker.ps('--all', '--format', '{{ .Names }}', _iter=True):
container = container.strip()
if container.startswith(prefix):
if prefix in container:
sh.docker.rm('-f', container, _ok_code=[0, 1])
leftovers.append('container(' + container + ')')
for network in sh.docker.network.ls('--format', '{{ .Name }}', _iter=True):
network = network.strip()
if network.startswith(prefix):
if prefix in network:
sh.docker.network.rm(network, _ok_code=[0, 1])
leftovers.append('network(' + network + ')')
for image in sh.docker.images('--format', '{{ .Repository }}:{{ .Tag }}', _iter=True):

16
tests/enough/common/data/common/data/docker-compose-fail.yml

@ -0,0 +1,16 @@
version: "3"
services:
enough-{{ this.name }}:
logging:
driver: json-file
healthcheck:
test: ["CMD", "/bin/false"]
interval: 1s
retries: 1
image: {{ this.get_image_name_with_version('base') }}
networks:
- enough-{{ this.name }}
networks:
enough-{{ this.name }}:

15
tests/enough/common/data/common/data/docker-compose.yml

@ -1,15 +0,0 @@
version: "3"
services:
one:
image: {{ this.get_image_name('base') }}
ports:
- {{ this.get_ports() }}
deploy:
restart_policy:
condition: none
networks:
- {{ this.name }}
networks:
{{ this.name }}:

123
tests/enough/common/test_common_docker.py

@ -1,9 +1,9 @@
import os
import sh
from enough.common import docker
import pytest
from tests.modified_environ import modified_environ
from enough.version import __version__
from enough.common import retry
class DockerFixture(docker.Docker):
@ -20,68 +20,21 @@ def test_init():
assert d.name == name
def test_is_local():
with modified_environ('DOCKER_HOST'):
assert DockerFixture('NAME').is_local()
with modified_environ(DOCKER_HOST="somehost"):
assert not DockerFixture('NAME').is_local()
def test_get_repository_name():
def test_get_image_name():
docker_name = 'DOCKER_NAME'
suffix = 'SUFFIX'
d = DockerFixture(docker_name)
assert d.get_repository_name(suffix) == docker_name + '_' + suffix
assert d.get_repository_name(None) == docker_name
assert d.get_image_name(suffix) == docker_name + '_' + suffix
assert d.get_image_name(None) == docker_name
def test_get_image_name():
def test_get_image_name_with_version():
docker_name = 'DOCKER_NAME'
suffix = 'SUFFIX'
with modified_environ('DOCKER_HOST'):
d = DockerFixture(docker_name)
repository = d.get_repository_name(suffix)
image_name = repository + ':' + str(__version__)
assert d.get_image_name(suffix) == image_name
with modified_environ(DOCKER_HOST="somehost"):
registry = 'REGISTRY'
namespace = 'NAMESPACE'
d = DockerFixture(docker_name,
registry=registry,
namespace=namespace)
repository = d.get_repository_name(suffix)
assert d.get_image_name(suffix) == registry + '/' + namespace + '/' + image_name
def test_get_requests_session():
docker_name = 'DOCKER_NAME'
with modified_environ('DOCKER_HOST'):
d = DockerFixture(docker_name)
s = d.get_requests_session()
assert s.trust_env is False
# verify session is cached
assert d.get_requests_session() == s
with modified_environ(DOCKER_HOST="somehost"):
d = DockerFixture(docker_name)
s = d.get_requests_session()
assert s.trust_env is True
def test_swarm_init():
docker_name = 'DOCKER_NAME'
with modified_environ('DOCKER_HOST'):
d = DockerFixture(docker_name,
docker='tests/enough/common/bin/test_swarm_init_docker/inactive.sh')
assert d.swarm_init() is True
d = DockerFixture(docker_name,
docker='tests/enough/common/bin/test_swarm_init_docker/active.sh')
assert d.swarm_init() is False
with modified_environ(DOCKER_HOST="somehost"):
d = DockerFixture(docker_name)
assert d.swarm_init() is None
image_name = d.get_image_name(suffix) + ':' + str(__version__)
assert d.get_image_name_with_version(suffix) == image_name
def test_replace_content():
@ -93,52 +46,42 @@ def test_replace_content():
assert d.replace_content(before) == after
class DockerFixtureIntegration(docker.Docker):
def __init__(self, *args, **kwargs):
super(DockerFixtureIntegration, self).__init__(*args, **kwargs)
self.root = 'tests/enough/common/data'
def get_compose_content(self):
f = os.path.join(self.root, 'common/data/docker-compose.yml')
return self.replace_content(open(f).read())
@pytest.mark.skipif('SKIP_INTEGRATION_TESTS' in os.environ, reason='skip integration test')
def test_inner_deploy(docker_name, tcp_port):
d = DockerFixtureIntegration(docker_name)
def test_inner_up(docker_name, tcp_port):
d = docker.Docker(docker_name, port=tcp_port)
assert d.create_image()
d.port = tcp_port
stack = d._deploy()
assert stack
ls = d.docker.service.ls('--filter', 'Name=' + stack, '--format', '{{ json . }}')
assert len(list(ls)) == 1
d._up()
assert d.inspect('{{ .Path }}') == ['/sbin/init']
@pytest.mark.skipif('SKIP_INTEGRATION_TESTS' in os.environ, reason='skip integration test')
def test_deploy_wait_for_service(docker_name, tcp_port):
d = DockerFixtureIntegration(docker_name)
assert d.deploy_wait_for_service({'port': tcp_port})
assert 'docker service logs' in d.get_stack_logs()
d.rm()
def test_up_wait_for_services(docker_name, tcp_port):
d = docker.Docker(docker_name, port=tcp_port)
d.up_wait_for_services()
assert '"Status":"healthy"' in d.get_logs()
d.down()
@pytest.mark.skipif('SKIP_INTEGRATION_TESTS' in os.environ, reason='skip integration test')
def test_get_network_peer_and_get_host_port(docker_name):
try:
sh.docker.stack.deploy('-c', 'tests/enough/common/get_network_peer/docker-compose.yml',
docker_name)
d = DockerFixtureIntegration(docker_name)
assert len(d.get_network_peer().split('.')) == 4
ip_service = 'ip_service'
port = '8080'
assert d.get_host_port(ip_service, port) == ip_service
assert d.get_host_port(None, port).endswith(':' + str(port))
finally:
sh.docker.stack.rm(docker_name)
def test_up_wait_for_services_fail(docker_name):
class DockerFixtureIntegration(docker.Docker):
def __init__(self, *args, **kwargs):
kwargs['root'] = 'tests/enough/common/data'
kwargs['retry'] = 2
super().__init__(*args, **kwargs)
def get_compose_content(self):
f = os.path.join(self.root, 'common/data/docker-compose-fail.yml')
return self.replace_content(open(f).read())
d = DockerFixtureIntegration(docker_name)
with pytest.raises(retry.RetryException):
d.up_wait_for_services()
d.down()
@pytest.mark.skipif('SKIP_INTEGRATION_TESTS' in os.environ, reason='skip integration test')
def test_create_image(docker_name):
d = DockerFixtureIntegration(docker_name)
d = docker.Docker(docker_name)
assert d.create_image().startswith(docker_name)

8
tests/enough/internal/test_internal_docker.py

@ -6,8 +6,14 @@ from io import StringIO
def test_enough_docker_image(docker_name):
assert main(['--debug', 'build', 'enough', 'image', '--name', docker_name]) == 0
image_name = Docker(docker_name).get_image_name(suffix=None)
image_name = Docker(docker_name).get_image_name_with_version(suffix=None)
out = StringIO()
sh.docker('image', 'ls', '--filter', 'reference=' + image_name,
'--format', '{{ .Repository }}:{{ .Tag }}', _out=out)
assert out.getvalue().strip() == image_name
image_name = Docker(docker_name).get_image_name(suffix=None)
out = StringIO()
sh.docker('image', 'ls', '--filter', 'reference=' + image_name + ':latest',
'--format', '{{ .Repository }}', _out=out)
assert out.getvalue().strip() == image_name

6
tox.ini

@ -1,5 +1,5 @@
[tox]
envlist = py3,pep8,docs
envlist = py3,flake8,docs
[testenv]
setenv = VIRTUAL_ENV={envdir}
@ -10,13 +10,13 @@ deps =
commands = coverage run --source=enough {envbindir}/py.test --durations 10 {posargs:tests}
coverage report --omit=*test*,*tox* --show-missing
[testenv:pep8]
[testenv:flake8]
commands = flake8 {posargs}
[testenv:docs]
commands = sphinx-build -W -vvv -b html docs build/html
[flake8]
exclude = venv,.tox,dist,doc,*.egg,build,docs/conf.py,src,molecule/postfix/roles/debops*
exclude = venv,.tox,dist,doc,*.egg,build,docs/conf.py,src,molecule/debops*
show-source = true
max_line_length = 100

Loading…
Cancel
Save