Skip to content

Commit 7ae4607

Browse files
committed
Support setting the mapping for build source path between container and host mechine
* Set the path mapping with global config *.yml file * If the mapping is not set on global config file, try to read the mapping from environment variable.
1 parent 43e2f77 commit 7ae4607

6 files changed

Lines changed: 148 additions & 18 deletions

File tree

apluslms_roman/backends/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import logging
12
from collections import namedtuple
23
from collections.abc import Mapping
4+
from pathlib import PurePosixPath
35

46
from ..observer import BuildObserver
57

8+
logger = logging.getLogger(__name__)
69

710
BACKENDS = {
811
'docker': 'apluslms_roman.backends.docker.DockerBackend',
@@ -112,3 +115,18 @@ def verify(self):
112115

113116
def version_info(self):
114117
pass
118+
119+
def get_host_path(self, original):
120+
mapping = self.environment.environ.get('directory_map', {})
121+
if not mapping:
122+
return original
123+
logger.debug("Get mapping from environment:%s", mapping)
124+
path = PurePosixPath(original)
125+
for container, host in mapping.items():
126+
try:
127+
logger.debug("Mapping:%s:%s", container, host)
128+
return str(PurePosixPath(host, path.relative_to(container)))
129+
except ValueError:
130+
logger.error("Error when composing new path!")
131+
continue
132+
return ValueError("Unable to map path '%s' to any backend host path" % (path))

apluslms_roman/backends/docker.py

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import docker
2-
from os.path import join
1+
import logging
2+
from os.path import join, expanduser
33

4+
import docker
45
from apluslms_yamlidator.utils.decorator import cached_property
56

67
from ..utils.translation import _
@@ -12,6 +13,8 @@
1213

1314
Mount = docker.types.Mount
1415

16+
logger = logging.getLogger(__name__)
17+
1518

1619
class DockerBackend(Backend):
1720
name = 'docker'
@@ -22,14 +25,28 @@ class DockerBackend(Backend):
2225
@cached_property
2326
def _client(self):
2427
env = self.environment.environ
25-
kwargs = {}
26-
version = env.get('DOCKER_VERSION', None)
27-
if version:
28-
kwargs['version'] = version
29-
timeout = env.get('DOCKER_TIMEOUT', None)
30-
if timeout:
31-
kwargs['timeout'] = timeout
32-
return docker.from_env(environment=env, **kwargs)
28+
params = {
29+
'base_url': env.get('host'),
30+
'version': env.get('version'),
31+
}
32+
if 'timeout' in env:
33+
params['timeout'] = env['timeout']
34+
35+
# false values: 0, false, '', unset
36+
# true values: 1, true, "yes"
37+
tls_verify = bool(env.get('tls_verify', False))
38+
cert_path = env.get('cert_path') or None
39+
if tls_verify or cert_path:
40+
if not cert_path:
41+
cert_path = join(expanduser('~'), '.docker')
42+
params['tls'] = docker.tls.TLSConfig(
43+
client_cert=(join(cert_path, 'cert.pem'), join(cert_path, 'key.pem')),
44+
ca_cert=join(cert_path, 'ca.pem'),
45+
verify=tls_verify,
46+
ssl_version=env.get('tls_ssl_version'),
47+
assert_hostname=tls_verify and env.get('tls_assert_hostname'),
48+
)
49+
return docker.DockerClient(**params)
3350

3451
def _run_opts(self, task, step):
3552
env = self.environment
@@ -41,16 +58,19 @@ def _run_opts(self, task, step):
4158
user='{}:{}'.format(env.uid, env.gid),
4259
)
4360

61+
path = self.get_host_path(task.path)
62+
63+
logger.debug("Final path is:%s", path)
4464
# mounts and workdir
4565
if step.mnt:
46-
opts['mounts'] = [Mount(step.mnt, task.path, type='bind', read_only=False)]
66+
opts['mounts'] = [Mount(step.mnt, path, type='bind', read_only=False)]
4767
opts['working_dir'] = step.mnt
4868
else:
4969
wpath = self.WORK_PATH
5070
opts['mounts'] = [
5171
Mount(wpath, None, type='tmpfs', read_only=False, tmpfs_size=self.WORK_SIZE),
52-
Mount(join(wpath, 'src'), task.path, type='bind', read_only=True),
53-
Mount(join(wpath, 'build'), join(task.path, '_build'), type='bind', read_only=False),
72+
Mount(join(wpath, 'src'), path, type='bind', read_only=True),
73+
Mount(join(wpath, 'build'), join(path, '_build'), type='bind', read_only=False),
5474
]
5575
opts['working_dir'] = wpath
5676

apluslms_roman/builder.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .utils.importing import import_string
1010
from .utils.translation import _
1111

12+
1213
class Builder:
1314
def __init__(self, engine, config, observer=None):
1415
if not isdir(config.dir):
@@ -21,7 +22,7 @@ def __init__(self, engine, config, observer=None):
2122

2223
def get_steps(self, refs: list = None):
2324
steps = [BuildStep.from_config(i, step)
24-
for i, step in enumerate(self.config.steps)]
25+
for i, step in enumerate(self.config.steps)]
2526
if refs:
2627
name_dict = {step.name: step for step in steps}
2728
refs = [int(ref) if ref.isdigit() else ref.lower() for ref in refs]
@@ -64,11 +65,9 @@ def __init__(self, backend_class=None, settings=None):
6465

6566
name = getattr(backend_class, 'name', None) or backend_class.__name__.lower()
6667
env_prefix = name.upper() + '_'
67-
env = {k: v for k, v in environ.items() if k.startswith(env_prefix)}
68+
env = {key: value for key, value in environ.items() if key.startswith(env_prefix)}
6869
if settings:
69-
for k, v in settings.get(name, {}).items():
70-
if v is not None and v != '':
71-
env[env_prefix + k.replace('-', '_').upper()] = v
70+
env.update(settings.get(name, {}))
7271
self._environment = Environment(getuid(), getegid(), env)
7372

7473
@cached_property

apluslms_roman/schemas/roman_settings-v1.0.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ properties:
2424
type: object
2525
additionalProperties: false
2626
properties:
27+
directory_map:
28+
title: docker container-host machine path mapping
29+
description: The dictornary mapping between docker and it's host
30+
type: object
2731
host:
2832
title: docker host
2933
description: the URL to the Docker host
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import json
2+
import logging
3+
import re
4+
from os import environ
5+
from apluslms_yamlidator.document import find_ml
6+
7+
logger = logging.getLogger(__name__)
8+
json_re = re.compile(r'^(?:["[{]|(?:-?[1-9]\d*(?:\.\d+)?|null|true|false)$)')
9+
10+
11+
def nest_dict(flat_dict):
12+
nested = {}
13+
for keys, value in flat_dict.items():
14+
dict_, key = find_ml(nested, keys, create_dicts=True)
15+
dict_[key] = value
16+
return nested
17+
18+
19+
def load_from_env(env_prefix=None, decode_json=True):
20+
if decode_json:
21+
decode = lambda s: json.loads(s) if json_re.match(s) is not None else s
22+
else:
23+
decode = lambda s: s
24+
env = {key[len(env_prefix):].lower(): decode(value) for key, value in environ.items() if key.startswith(env_prefix)}
25+
return nest_dict(env)

tests/utils/test_path_mapping.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import os
2+
import unittest
3+
from json import loads
4+
5+
from apluslms_roman.utils.path_mapping import json_re, load_from_env
6+
7+
test_case_loadable = (
8+
'true',
9+
'false',
10+
'null',
11+
'123',
12+
'-123',
13+
'3.14',
14+
'-3.14',
15+
'{"foo": "bar"}',
16+
'[1, 2, 3]',
17+
'"foo bar"'
18+
)
19+
20+
test_case_not_loadable = (
21+
"/foobar.py",
22+
"text",
23+
"yes",
24+
"0123123",
25+
)
26+
27+
28+
class TestJsonLoadable(unittest.TestCase):
29+
30+
def test_loadable_not_raise(self):
31+
for case in test_case_loadable:
32+
with self.subTest(non_json=case):
33+
loads(case)
34+
35+
def test_not_loadable_raise(self):
36+
for case in test_case_not_loadable:
37+
with self.subTest(non_json=case):
38+
with self.assertRaises(ValueError, msg="Testing:{}".format(case)):
39+
loads(case)
40+
41+
42+
class TestJsonRegex(unittest.TestCase):
43+
44+
def test_loadable_match(self):
45+
for case in test_case_loadable:
46+
with self.subTest(non_json=case):
47+
self.assertTrue(json_re.match(case)is not None, msg="Testing:{}".format(case))
48+
49+
def test_not_loadable_not_match(self):
50+
for case in test_case_not_loadable:
51+
with self.subTest(non_json=case):
52+
self.assertFalse(json_re.match(case) is not None, msg="Testing:{}".format(case))
53+
54+
55+
class TestLoadFromEnv(unittest.TestCase):
56+
57+
def test_with_decode_json(self):
58+
os.environ['DOCKER.FOO.BAR'] = '123'
59+
self.assertEqual({'foo': {'bar': '123'}}, load_from_env('DOCKER.', False))
60+
61+
def test_without_decode_json(self):
62+
os.environ['DOCKER.FOO.BAR'] = '123'
63+
self.assertEqual({'foo': {'bar': 123}}, load_from_env('DOCKER.', True))
64+

0 commit comments

Comments
 (0)