diff --git a/ddb/feature/docker/actions.py b/ddb/feature/docker/actions.py index 1956931d..3e7fa1ac 100644 --- a/ddb/feature/docker/actions.py +++ b/ddb/feature/docker/actions.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- +import keyword import os import re -import keyword from pathlib import PurePosixPath, Path from typing import Union, Iterable, List, Dict, Set @@ -292,6 +292,9 @@ def execute(self, docker_compose_config: dict): volume_mappings = self._get_volume_mappings(docker_compose_config, external_volumes) + volume_mappings = [(source, target) for (source, target) in volume_mappings if + self._check_file_in_project(source)] + for source, target in volume_mappings: self._create_local_volume(source, target) @@ -328,6 +331,12 @@ def _fix_owner(relative_path): config.data.get("docker.user.uid"), config.data.get("docker.user.gid"), relative_path) + @staticmethod + def _check_file_in_project(target): + target_path = os.path.realpath(target) + cwd = os.path.realpath(".") + return target_path.startswith(cwd) + @staticmethod def _create_local_volume(source, target): rel_source = os.path.relpath(source, ".") @@ -335,7 +344,9 @@ def _create_local_volume(source, target): context.log.notice("Local volume source: %s (exists)", rel_source) else: _, source_ext = os.path.splitext(source) - _, target_ext = os.path.splitext(target) + target_ext = None + if target: + _, target_ext = os.path.splitext(target) if source_ext or target_ext: # Create empty file, because with have an extension in source or target. os.makedirs(str(Path(source).parent), exist_ok=True) @@ -352,7 +363,9 @@ def _create_local_volume(source, target): @staticmethod def _get_volume_mappings(docker_compose_config, external_volumes): volume_mappings = [] - for service in docker_compose_config['services'].values(): + external_volumes_dict = {} + + for service in docker_compose_config.get('services', {}).values(): if 'volumes' not in service: continue @@ -364,12 +377,26 @@ def _get_volume_mappings(docker_compose_config, external_volumes): source, target, _ = volume_spec.rsplit(':', 2) if source in external_volumes: + external_volumes_dict[source] = target continue volume_mapping = (source, target) if volume_mapping not in volume_mappings: volume_mappings.append(volume_mapping) + + for volume, volume_config in docker_compose_config.get('volumes', {}).items(): + if volume_config and volume_config.get('driver') == 'local' and volume_config.get('driver_opts'): + driver_opts = volume_config.get('driver_opts') + device = driver_opts.get('device') + if device and driver_opts.get('o') == 'bind': + source = PurePosixPath(device).joinpath(volume) + target = external_volumes_dict.get(volume) + if source and target: + volume_mapping = (source, target) + if volume_mapping not in volume_mappings: + volume_mappings.append(volume_mapping) + return volume_mappings diff --git a/ddb/feature/jsonnet/lib/ddb.docker.libjsonnet b/ddb/feature/jsonnet/lib/ddb.docker.libjsonnet index 6d992608..8a463863 100644 --- a/ddb/feature/jsonnet/lib/ddb.docker.libjsonnet +++ b/ddb/feature/jsonnet/lib/ddb.docker.libjsonnet @@ -32,6 +32,9 @@ local _docker_compose_excluded_services = std.extVar("jsonnet.docker.compose.exc local _docker_compose_included_services = std.extVar("jsonnet.docker.compose.included_services"); local _docker_expose_disabled = std.extVar("jsonnet.docker.expose.disabled"); local _docker_expose_port_prefix = std.extVar("jsonnet.docker.expose.port_prefix"); +local _docker_mount_disabled = std.extVar("jsonnet.docker.mount.disabled"); +local _docker_mount_directory = std.extVar("jsonnet.docker.mount.directory"); +local _docker_mount_directories = std.extVar("jsonnet.docker.mount.directories"); local _core_project_name = std.extVar("core.project.name"); local _core_os = std.extVar("core.os"); local _core_env_current = std.extVar("core.env.current"); @@ -52,6 +55,19 @@ local path = { home: mapPath(_core_path_home) }; +local File(thisFile) = + local splitFile = if std.isArray(thisFile) then thisFile else std.split(thisFile, "/"); + + local name = splitFile[std.length(splitFile) - 1]; + if std.length(splitFile) >= 2 then + local parent = std.slice(splitFile, 0, std.length(splitFile) - 1, 1); + { + name: name, + parent: File(parent) + } + else + {name: name}; + local Service(restart=_docker_service_restart, init=_docker_service_init) = { [ if restart != null then "restart"]: restart, [ if init then "init"]: true, @@ -149,19 +165,44 @@ local apply_resolve_ports_conflicts(compose) = local ServiceName(name=null) = std.join("-", std.prune([_core_project_name, name])); -local Volumes(services) = { - [key]: {} for key in - std.set( - std.filter(volume_is_named, - std.map(volume_source, - std.flatMap(function (f) if std.objectHas(services[f], "volumes") then services[f].volumes else [], - std.objectFields(services) - ) - ) - ) - ) +local _ensure_absolute(path) = + if std.startsWith(path, '/') then + path + else + _core_path_project_home + '/' + path; + +local apply_volumes_mounts(volumes) = { + [key]: volumes[key] + + (if _docker_mount_directories != null && std.objectHas(_docker_mount_directories, key) then { + driver: 'local', + driver_opts: {type: 'none', o: 'bind', device: _ensure_absolute(_docker_mount_directories[key])} + } else if _docker_mount_directory != null then { + driver: 'local', + driver_opts: {type: 'none', o: 'bind', device: _ensure_absolute(_docker_mount_directory + '/' + key)} + } + else {}) + for key in std.objectFields(volumes) }; +local Volumes(services) = + local volumes = { + [key]: {} for key in + std.set( + std.filter(volume_is_named, + std.map(volume_source, + std.flatMap(function (f) if std.objectHas(services[f], "volumes") then services[f].volumes else [], + std.objectFields(services) + ) + ) + ) + ) + }; + + if !_docker_mount_disabled then + apply_volumes_mounts(volumes) + else + volumes; + local NoBinaryOptionsLabels(name, options, options_condition = null, index = null) = {}; local BinaryOptionsLabels(name, options, options_condition = null, index = null) = { ["ddb.emit.docker:binary[" + name + "](options)" + (if index != null then "(c" + index + ")" else "")]: options, @@ -319,19 +360,6 @@ local envIndex(env=_core_env_current) = local envIs(env) = env == _core_env_current; -local File(thisFile) = - local splitFile = if std.isArray(thisFile) then thisFile else std.split(thisFile, "/"); - - local name = splitFile[std.length(splitFile) - 1]; - if std.length(splitFile) >= 2 then - local parent = std.slice(splitFile, 0, std.length(splitFile) - 1, 1); - { - name: name, - parent: File(parent) - } - else - {name: name}; - local with(package, params={}, append=null, name=null, when=true) = if when then local effectiveName = if name == null then package.defaultName else name; diff --git a/ddb/feature/jsonnet/schema.py b/ddb/feature/jsonnet/schema.py index a2255dce..cbe3bb50 100644 --- a/ddb/feature/jsonnet/schema.py +++ b/ddb/feature/jsonnet/schema.py @@ -79,6 +79,15 @@ class ExposeSchema(DisableableSchema): port_prefix = fields.Integer(required=False) # default is set in feature _configure_defaults +class MountSchema(DisableableSchema): + """ + Mount schema + """ + directory = fields.String(required=False, allow_none=True, default=None) + directories = fields.Dict(required=False, allow_none=True, default=None, + keys=fields.String(), values=fields.String()) + + class UserSchema(DisableableSchema): """ User schema @@ -119,6 +128,7 @@ class DockerSchema(Schema): build = fields.Nested(BuildSchema(), default=BuildSchema()) service = fields.Nested(ServiceSchema(), default=ServiceSchema()) expose = fields.Nested(ExposeSchema(), default=ExposeSchema()) + mount = fields.Nested(MountSchema(), default=MountSchema()) registry = fields.Nested(RegistrySchema(), default=RegistrySchema()) user = fields.Nested(UserSchema(), default=UserSchema()) binary = fields.Nested(BinarySchema(), default=BinarySchema()) diff --git a/docs/features/jsonnet.md b/docs/features/jsonnet.md index 13980f4b..5735f53d 100644 --- a/docs/features/jsonnet.md +++ b/docs/features/jsonnet.md @@ -84,6 +84,14 @@ Run `ddb configure` to evaluate templates and generate target files. | `port_prefix` | integer
`` | Port prefix. | +!!! summary "Docker Mount configuration (prefixed with `jsonnet.docker.mount.`)" + | Property | Type | Description | + | :---------: | :----: | :----------- | + | `disabled` | boolean
`False` | Should `ddb.Expose()` perform nothing ? | + | `directory` | string | Base directory for all named volume mounts, absolute or relative to project home. | + | `directories` | dict[string, string] | Directories for named volume mounts, absolute or relative to project home. key is the volume name, value is the local path to mount. | + + !!! summary "Docker Registry configuration (prefixed with `jsonnet.docker.registry.`)" | Property | Type | Description | | :---------: | :----: | :----------- | diff --git a/tests/feature/jsonnet/test_jsonnet.data/docker_compose_expose/docker-compose.expected.yml b/tests/feature/jsonnet/test_jsonnet.data/docker_compose_expose/docker-compose.expected.yml index 9cc80e9e..ba76cbe4 100644 --- a/tests/feature/jsonnet/test_jsonnet.data/docker_compose_expose/docker-compose.expected.yml +++ b/tests/feature/jsonnet/test_jsonnet.data/docker_compose_expose/docker-compose.expected.yml @@ -5,9 +5,9 @@ services: init: true restart: 'no' ports: - - '14721:21' - - '14722:22/udp' - - '14799:23/tcp' + - '41621:21' + - '41622:22/udp' + - '41699:23/tcp' - '9912:9912' volumes: {} version: '3.7' \ No newline at end of file diff --git a/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_single_volume/ddb.yml b/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_single_volume/ddb.yml new file mode 100644 index 00000000..cf70b069 --- /dev/null +++ b/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_single_volume/ddb.yml @@ -0,0 +1,5 @@ +jsonnet: + docker: + mount: + directories: + shared-volume: 'volumes/shared-volume' \ No newline at end of file diff --git a/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_single_volume/docker-compose.expected.yml b/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_single_volume/docker-compose.expected.yml new file mode 100644 index 00000000..b913ece7 --- /dev/null +++ b/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_single_volume/docker-compose.expected.yml @@ -0,0 +1,36 @@ +networks: {} +services: + s1: + image: alpine:3.6 + init: true + restart: 'no' + volumes: + - shared-volume:/share + s2: + image: alpine:3.6 + init: true + restart: 'no' + volumes: + - another-volume:/another + s3: + image: alpine:3.6 + init: true + restart: 'no' + volumes: + - shared-volume:/share + s4: + image: alpine:3.6 + init: true + restart: 'no' + volumes: + - another-volume2:/share +version: '3.7' +volumes: + another-volume: {} + shared-volume: + driver: local + driver_opts: + type: 'none' + o: 'bind' + device: '%ddb.path.project%/volumes/shared-volume' + another-volume2: {} \ No newline at end of file diff --git a/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_single_volume/docker-compose.yml.jsonnet b/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_single_volume/docker-compose.yml.jsonnet new file mode 100644 index 00000000..6b7c089e --- /dev/null +++ b/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_single_volume/docker-compose.yml.jsonnet @@ -0,0 +1,25 @@ +local ddb = import 'ddb.docker.libjsonnet'; + +ddb.Compose({ + services: { + s1: + ddb.Image("alpine:3.6") + + { + volumes+: ['shared-volume:/share'], + }, + s2: ddb.Image("alpine:3.6") + + { + volumes+: ["another-volume:/another"], + }, + s3: + ddb.Image("alpine:3.6") + + { + volumes+: ['shared-volume:/share'] + }, + s4: + ddb.Image("alpine:3.6") + + { + volumes+: ['another-volume2:/share'] + }, + }, +}) diff --git a/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_single_volume_with_default/ddb.yml b/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_single_volume_with_default/ddb.yml new file mode 100644 index 00000000..f5b401c6 --- /dev/null +++ b/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_single_volume_with_default/ddb.yml @@ -0,0 +1,6 @@ +jsonnet: + docker: + mount: + directory: '/default' + directories: + shared-volume: 'shared-volume' \ No newline at end of file diff --git a/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_single_volume_with_default/docker-compose.expected.yml b/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_single_volume_with_default/docker-compose.expected.yml new file mode 100644 index 00000000..b7ef76a3 --- /dev/null +++ b/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_single_volume_with_default/docker-compose.expected.yml @@ -0,0 +1,46 @@ +networks: {} +services: + s1: + image: alpine:3.6 + init: true + restart: 'no' + volumes: + - shared-volume:/share + s2: + image: alpine:3.6 + init: true + restart: 'no' + volumes: + - another-volume:/another + s3: + image: alpine:3.6 + init: true + restart: 'no' + volumes: + - shared-volume:/share + s4: + image: alpine:3.6 + init: true + restart: 'no' + volumes: + - another-volume2:/share +version: '3.7' +volumes: + another-volume: + driver: local + driver_opts: + type: 'none' + o: 'bind' + device: '/default/another-volume' + shared-volume: + driver: local + driver_opts: + type: 'none' + o: 'bind' + device: '%ddb.path.project%/shared-volume' + another-volume2: + driver: local + driver_opts: + type: 'none' + o: 'bind' + device: '/default/another-volume2' \ No newline at end of file diff --git a/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_single_volume_with_default/docker-compose.yml.jsonnet b/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_single_volume_with_default/docker-compose.yml.jsonnet new file mode 100644 index 00000000..6b7c089e --- /dev/null +++ b/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_single_volume_with_default/docker-compose.yml.jsonnet @@ -0,0 +1,25 @@ +local ddb = import 'ddb.docker.libjsonnet'; + +ddb.Compose({ + services: { + s1: + ddb.Image("alpine:3.6") + + { + volumes+: ['shared-volume:/share'], + }, + s2: ddb.Image("alpine:3.6") + + { + volumes+: ["another-volume:/another"], + }, + s3: + ddb.Image("alpine:3.6") + + { + volumes+: ['shared-volume:/share'] + }, + s4: + ddb.Image("alpine:3.6") + + { + volumes+: ['another-volume2:/share'] + }, + }, +}) diff --git a/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_volumes/ddb.yml b/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_volumes/ddb.yml new file mode 100644 index 00000000..6778d9be --- /dev/null +++ b/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_volumes/ddb.yml @@ -0,0 +1,4 @@ +jsonnet: + docker: + mount: + directory: '/custom' \ No newline at end of file diff --git a/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_volumes/docker-compose.expected.yml b/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_volumes/docker-compose.expected.yml new file mode 100644 index 00000000..b6e048dd --- /dev/null +++ b/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_volumes/docker-compose.expected.yml @@ -0,0 +1,34 @@ +networks: {} +services: + s1: + image: alpine:3.6 + init: true + restart: 'no' + volumes: + - shared-volume:/share + s2: + image: alpine:3.6 + init: true + restart: 'no' + volumes: + - another-volume:/another + s3: + image: alpine:3.6 + init: true + restart: 'no' + volumes: + - shared-volume:/share +version: '3.7' +volumes: + another-volume: + driver: local + driver_opts: + type: 'none' + o: 'bind' + device: '/custom/another-volume' + shared-volume: + driver: local + driver_opts: + type: 'none' + o: 'bind' + device: '/custom/shared-volume' \ No newline at end of file diff --git a/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_volumes/docker-compose.yml.jsonnet b/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_volumes/docker-compose.yml.jsonnet new file mode 100644 index 00000000..a6c183b8 --- /dev/null +++ b/tests/feature/jsonnet/test_jsonnet.data/docker_compose_mount_volumes/docker-compose.yml.jsonnet @@ -0,0 +1,20 @@ +local ddb = import 'ddb.docker.libjsonnet'; + +ddb.Compose({ + services: { + s1: + ddb.Image("alpine:3.6") + + { + volumes+: ['shared-volume:/share'], + }, + s2: ddb.Image("alpine:3.6") + + { + volumes+: ["another-volume:/another"], + }, + s3: + ddb.Image("alpine:3.6") + + { + volumes+: ['shared-volume:/share'] + }, + }, +}) diff --git a/tests/feature/jsonnet/test_jsonnet.py b/tests/feature/jsonnet/test_jsonnet.py index 5403ce71..06a9a8d1 100644 --- a/tests/feature/jsonnet/test_jsonnet.py +++ b/tests/feature/jsonnet/test_jsonnet.py @@ -43,23 +43,23 @@ def test_empty_project_with_core(self, project_loader): @pytest.mark.skipif("os.name == 'nt'") def test_named_user_group(self, project_loader): - project_loader("empty") + project_loader("empty") - features.register(JsonnetFeature()) - load_registered_features() + features.register(JsonnetFeature()) + load_registered_features() - assert config.data.get('jsonnet.docker.user.name_to_uid') - assert config.data.get('jsonnet.docker.user.group_to_gid') + assert config.data.get('jsonnet.docker.user.name_to_uid') + assert config.data.get('jsonnet.docker.user.group_to_gid') @pytest.mark.skipif("os.name != 'nt'") def test_named_user_group_windows(self, project_loader): - project_loader("empty") + project_loader("empty") - features.register(JsonnetFeature()) - load_registered_features() + features.register(JsonnetFeature()) + load_registered_features() - assert config.data.get('jsonnet.docker.user.name_to_uid') == {} - assert config.data.get('jsonnet.docker.user.group_to_gid') == {} + assert config.data.get('jsonnet.docker.user.name_to_uid') == {} + assert config.data.get('jsonnet.docker.user.group_to_gid') == {} def test_example1(self, project_loader): project_loader("example1") @@ -436,34 +436,37 @@ def test_docker_compose_excluded_services(self, project_loader): assert rendered == expected def test_docker_compose_included_services(self, project_loader): - project_loader("docker_compose_included_services") + project_loader("docker_compose_included_services") - features.register(CoreFeature()) - features.register(FileFeature()) - features.register(DockerFeature()) - features.register(JsonnetFeature()) - load_registered_features() - register_actions_in_event_bus(True) + features.register(CoreFeature()) + features.register(FileFeature()) + features.register(DockerFeature()) + features.register(JsonnetFeature()) + load_registered_features() + register_actions_in_event_bus(True) - action = FileWalkAction() - action.initialize() - action.execute() + action = FileWalkAction() + action.initialize() + action.execute() - assert os.path.exists('docker-compose.yml') - with open('docker-compose.yml', 'r') as f: - rendered = yaml.load(f.read(), yaml.SafeLoader) + assert os.path.exists('docker-compose.yml') + with open('docker-compose.yml', 'r') as f: + rendered = yaml.load(f.read(), yaml.SafeLoader) - with open('docker-compose.expected.yml', 'r') as f: - expected_data = f.read() - expected = yaml.load(expected_data, yaml.SafeLoader) + with open('docker-compose.expected.yml', 'r') as f: + expected_data = f.read() + expected = yaml.load(expected_data, yaml.SafeLoader) - assert rendered == expected + assert rendered == expected @pytest.mark.parametrize("variant", [ "_register_binary", "_register_binary_with_one_option", # "_register_binary_with_multiple_options", TODO handle (options)(c1) "_shared_volumes", + "_mount_volumes", + "_mount_single_volume", + "_mount_single_volume_with_default", "_expose" ]) def test_docker_compose_variants(self, project_loader, variant): @@ -502,6 +505,12 @@ def test_docker_compose_variants(self, project_loader, variant): assert rendered == expected + if variant == '_mount_single_volume': + assert os.path.isdir('volumes/shared-volume') + + if variant == '_mount_single_volume_with_default': + assert os.path.isdir('shared-volume') + @pytest.mark.parametrize("variant", [ "default", "no_debug",