From a6c31b80fefc68e64c3ad57abb6f64541460453d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 21 Feb 2018 18:32:45 -0800 Subject: [PATCH] Add support for seccomp files Signed-off-by: Joffrey F --- compose/cli/docker_client.py | 2 +- compose/cli/utils.py | 8 ------- compose/config/config.py | 15 +++++++++++-- compose/config/serialize.py | 1 + compose/config/types.py | 29 +++++++++++++++++++++++++ compose/service.py | 6 +++++- compose/utils.py | 8 +++++++ tests/integration/project_test.py | 36 ++++++++++++++++++++++++++++++- tests/integration/service_test.py | 5 +++-- tests/unit/cli/utils_test.py | 2 +- 10 files changed, 96 insertions(+), 16 deletions(-) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index cc8993d7f..73a7b7e10 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -13,9 +13,9 @@ from docker.utils.config import home_dir from ..config.environment import Environment from ..const import HTTP_TIMEOUT +from ..utils import unquote_path from .errors import UserError from .utils import generate_user_agent -from .utils import unquote_path log = logging.getLogger(__name__) diff --git a/compose/cli/utils.py b/compose/cli/utils.py index a171d6678..4cc055cc9 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -131,14 +131,6 @@ def generate_user_agent(): return " ".join(parts) -def unquote_path(s): - if not s: - return s - if s[0] == '"' and s[-1] == '"': - return s[1:-1] - return s - - def human_readable_file_size(size): suffixes = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', ] order = int(math.log(size, 2) / 10) if size else 0 diff --git a/compose/config/config.py b/compose/config/config.py index b7764dd3b..38e887417 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -40,6 +40,7 @@ from .sort_services import sort_service_dicts from .types import MountSpec from .types import parse_extra_hosts from .types import parse_restart_spec +from .types import SecurityOpt from .types import ServiceLink from .types import ServicePort from .types import VolumeFromSpec @@ -734,9 +735,9 @@ def process_service(service_config): if field in service_dict: service_dict[field] = to_list(service_dict[field]) - service_dict = process_blkio_config(process_ports( + service_dict = process_security_opt(process_blkio_config(process_ports( process_healthcheck(service_dict) - )) + ))) return service_dict @@ -1376,6 +1377,16 @@ def split_path_mapping(volume_path): return (volume_path, None) +def process_security_opt(service_dict): + security_opts = service_dict.get('security_opt', []) + result = [] + for value in security_opts: + result.append(SecurityOpt.parse(value)) + if result: + service_dict['security_opt'] = result + return service_dict + + def join_path_mapping(pair): (container, host) = pair if isinstance(host, dict): diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 7fb9360a2..7de7f41e8 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -42,6 +42,7 @@ def serialize_string(dumper, data): yaml.SafeDumper.add_representer(types.MountSpec, serialize_dict_type) yaml.SafeDumper.add_representer(types.VolumeFromSpec, serialize_config_type) yaml.SafeDumper.add_representer(types.VolumeSpec, serialize_config_type) +yaml.SafeDumper.add_representer(types.SecurityOpt, serialize_config_type) yaml.SafeDumper.add_representer(types.ServiceSecret, serialize_dict_type) yaml.SafeDumper.add_representer(types.ServiceConfig, serialize_dict_type) yaml.SafeDumper.add_representer(types.ServicePort, serialize_dict_type) diff --git a/compose/config/types.py b/compose/config/types.py index d84491d0a..47e7222a3 100644 --- a/compose/config/types.py +++ b/compose/config/types.py @@ -4,6 +4,7 @@ Types for objects parsed from the configuration. from __future__ import absolute_import from __future__ import unicode_literals +import json import ntpath import os import re @@ -13,6 +14,7 @@ import six from docker.utils.ports import build_port_bindings from ..const import COMPOSEFILE_V1 as V1 +from ..utils import unquote_path from .errors import ConfigurationError from compose.const import IS_WINDOWS_PLATFORM from compose.utils import splitdrive @@ -457,3 +459,30 @@ def normalize_port_dict(port): external_ip=port.get('external_ip', ''), has_ext_ip=(':' if port.get('external_ip') else ''), ) + + +class SecurityOpt(namedtuple('_SecurityOpt', 'value src_file')): + @classmethod + def parse(cls, value): + # based on https://github.com/docker/cli/blob/9de1b162f/cli/command/container/opts.go#L673-L697 + con = value.split('=', 2) + if len(con) == 1 and con[0] != 'no-new-privileges': + if ':' not in value: + raise ConfigurationError('Invalid security_opt: {}'.format(value)) + con = value.split(':', 2) + + if con[0] == 'seccomp' and con[1] != 'unconfined': + try: + with open(unquote_path(con[1]), 'r') as f: + seccomp_data = json.load(f) + except (IOError, ValueError) as e: + raise ConfigurationError('Error reading seccomp profile: {}'.format(e)) + return cls( + 'seccomp={}'.format(json.dumps(seccomp_data)), con[1] + ) + return cls(value, None) + + def repr(self): + if self.src_file is not None: + return 'seccomp:{}'.format(self.src_file) + return self.value diff --git a/compose/service.py b/compose/service.py index 3918a19e8..37368d64d 100644 --- a/compose/service.py +++ b/compose/service.py @@ -881,6 +881,10 @@ class Service(object): init_path = options.get('init') options['init'] = True + security_opt = [ + o.value for o in options.get('security_opt') + ] if options.get('security_opt') else None + nano_cpus = None if 'cpus' in options: nano_cpus = int(options.get('cpus') * NANOCPUS_SCALE) @@ -910,7 +914,7 @@ class Service(object): extra_hosts=options.get('extra_hosts'), read_only=options.get('read_only'), pid_mode=self.pid_mode.mode, - security_opt=options.get('security_opt'), + security_opt=security_opt, ipc_mode=options.get('ipc'), cgroup_parent=options.get('cgroup_parent'), cpu_quota=options.get('cpu_quota'), diff --git a/compose/utils.py b/compose/utils.py index 00b01df2e..956673b4b 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -143,3 +143,11 @@ def parse_bytes(n): return sdk_parse_bytes(n) except DockerException: return None + + +def unquote_path(s): + if not s: + return s + if s[0] == '"' and s[-1] == '"': + return s[1:-1] + return s diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index b0e55f2d0..0acb80284 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1,8 +1,10 @@ from __future__ import absolute_import from __future__ import unicode_literals -import os.path +import json +import os import random +import tempfile import py import pytest @@ -1834,3 +1836,35 @@ class ProjectTest(DockerClientTestCase): assert 'svc1' in svc2.get_dependency_names() with pytest.raises(NoHealthCheckConfigured): svc1.is_healthy() + + def test_project_up_seccomp_profile(self): + seccomp_data = { + 'defaultAction': 'SCMP_ACT_ALLOW', + 'syscalls': [] + } + fd, profile_path = tempfile.mkstemp('_seccomp.json') + self.addCleanup(os.remove, profile_path) + with os.fdopen(fd, 'w') as f: + json.dump(seccomp_data, f) + + config_dict = { + 'version': '2.3', + 'services': { + 'svc1': { + 'image': 'busybox:latest', + 'command': 'top', + 'security_opt': ['seccomp:"{}"'.format(profile_path)] + } + } + } + + config_data = load_config(config_dict) + project = Project.from_config(name='composetest', config_data=config_data, client=self.client) + project.up() + containers = project.containers() + assert len(containers) == 1 + + remote_secopts = containers[0].get('HostConfig.SecurityOpt') + assert len(remote_secopts) == 1 + assert remote_secopts[0].startswith('seccomp=') + assert json.loads(remote_secopts[0].lstrip('seccomp=')) == seccomp_data diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 0bc902aea..d1a704199 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -23,6 +23,7 @@ from .testcases import SWARM_SKIP_CONTAINERS_ALL from .testcases import SWARM_SKIP_CPU_SHARES from compose import __version__ from compose.config.types import MountSpec +from compose.config.types import SecurityOpt from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec from compose.const import IS_WINDOWS_PLATFORM @@ -238,11 +239,11 @@ class ServiceTest(DockerClientTestCase): }] def test_create_container_with_security_opt(self): - security_opt = ['label:disable'] + security_opt = [SecurityOpt.parse('label:disable')] service = self.create_service('db', security_opt=security_opt) container = service.create_container() service.start_container(container) - assert set(container.get('HostConfig.SecurityOpt')) == set(security_opt) + assert set(container.get('HostConfig.SecurityOpt')) == set([o.repr() for o in security_opt]) @pytest.mark.xfail(True, reason='Not supported on most drivers') def test_create_container_with_storage_opt(self): diff --git a/tests/unit/cli/utils_test.py b/tests/unit/cli/utils_test.py index 066fb3595..26524ff37 100644 --- a/tests/unit/cli/utils_test.py +++ b/tests/unit/cli/utils_test.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import unittest -from compose.cli.utils import unquote_path +from compose.utils import unquote_path class UnquotePathTest(unittest.TestCase):