Merge pull request #4368 from dnephin/secrets-using-bind-mounts

Secrets using bind mounts
This commit is contained in:
Joffrey F 2017-02-01 14:11:20 -08:00 committed by GitHub
commit c9eb9380ed
11 changed files with 682 additions and 79 deletions

View File

@ -12,10 +12,12 @@ import six
import yaml import yaml
from cached_property import cached_property from cached_property import cached_property
from . import types
from ..const import COMPOSEFILE_V1 as V1 from ..const import COMPOSEFILE_V1 as V1
from ..const import COMPOSEFILE_V2_0 as V2_0 from ..const import COMPOSEFILE_V2_0 as V2_0
from ..const import COMPOSEFILE_V2_1 as V2_1 from ..const import COMPOSEFILE_V2_1 as V2_1
from ..const import COMPOSEFILE_V3_0 as V3_0 from ..const import COMPOSEFILE_V3_0 as V3_0
from ..const import COMPOSEFILE_V3_1 as V3_1
from ..utils import build_string_dict from ..utils import build_string_dict
from ..utils import parse_nanoseconds_int from ..utils import parse_nanoseconds_int
from ..utils import splitdrive from ..utils import splitdrive
@ -82,6 +84,7 @@ DOCKER_CONFIG_KEYS = [
'privileged', 'privileged',
'read_only', 'read_only',
'restart', 'restart',
'secrets',
'security_opt', 'security_opt',
'shm_size', 'shm_size',
'stdin_open', 'stdin_open',
@ -202,8 +205,11 @@ class ConfigFile(namedtuple('_ConfigFile', 'filename config')):
def get_networks(self): def get_networks(self):
return {} if self.version == V1 else self.config.get('networks', {}) return {} if self.version == V1 else self.config.get('networks', {})
def get_secrets(self):
return {} if self.version < V3_1 else self.config.get('secrets', {})
class Config(namedtuple('_Config', 'version services volumes networks')):
class Config(namedtuple('_Config', 'version services volumes networks secrets')):
""" """
:param version: configuration version :param version: configuration version
:type version: int :type version: int
@ -328,6 +334,7 @@ def load(config_details):
networks = load_mapping( networks = load_mapping(
config_details.config_files, 'get_networks', 'Network' config_details.config_files, 'get_networks', 'Network'
) )
secrets = load_secrets(config_details.config_files, config_details.working_dir)
service_dicts = load_services(config_details, main_file) service_dicts = load_services(config_details, main_file)
if main_file.version != V1: if main_file.version != V1:
@ -342,7 +349,7 @@ def load(config_details):
"`docker stack deploy` to deploy to a swarm." "`docker stack deploy` to deploy to a swarm."
.format(", ".join(sorted(s['name'] for s in services_using_deploy)))) .format(", ".join(sorted(s['name'] for s in services_using_deploy))))
return Config(main_file.version, service_dicts, volumes, networks) return Config(main_file.version, service_dicts, volumes, networks, secrets)
def load_mapping(config_files, get_func, entity_type): def load_mapping(config_files, get_func, entity_type):
@ -356,22 +363,12 @@ def load_mapping(config_files, get_func, entity_type):
external = config.get('external') external = config.get('external')
if external: if external:
if len(config.keys()) > 1: validate_external(entity_type, name, config)
raise ConfigurationError(
'{} {} declared as external but specifies'
' additional attributes ({}). '.format(
entity_type,
name,
', '.join([k for k in config.keys() if k != 'external'])
)
)
if isinstance(external, dict): if isinstance(external, dict):
config['external_name'] = external.get('name') config['external_name'] = external.get('name')
else: else:
config['external_name'] = name config['external_name'] = name
mapping[name] = config
if 'driver_opts' in config: if 'driver_opts' in config:
config['driver_opts'] = build_string_dict( config['driver_opts'] = build_string_dict(
config['driver_opts'] config['driver_opts']
@ -383,6 +380,39 @@ def load_mapping(config_files, get_func, entity_type):
return mapping return mapping
def validate_external(entity_type, name, config):
if len(config.keys()) <= 1:
return
raise ConfigurationError(
"{} {} declared as external but specifies additional attributes "
"({}).".format(
entity_type, name, ', '.join(k for k in config if k != 'external')))
def load_secrets(config_files, working_dir):
mapping = {}
for config_file in config_files:
for name, config in config_file.get_secrets().items():
mapping[name] = config or {}
if not config:
continue
external = config.get('external')
if external:
validate_external('Secret', name, config)
if isinstance(external, dict):
config['external_name'] = external.get('name')
else:
config['external_name'] = name
if 'file' in config:
config['file'] = expand_path(working_dir, config['file'])
return mapping
def load_services(config_details, config_file): def load_services(config_details, config_file):
def build_service(service_name, service_dict, service_names): def build_service(service_name, service_dict, service_names):
service_config = ServiceConfig.with_abs_paths( service_config = ServiceConfig.with_abs_paths(
@ -820,6 +850,7 @@ def merge_service_dicts(base, override, version):
md.merge_mapping('sysctls', parse_sysctls) md.merge_mapping('sysctls', parse_sysctls)
md.merge_mapping('depends_on', parse_depends_on) md.merge_mapping('depends_on', parse_depends_on)
md.merge_sequence('links', ServiceLink.parse) md.merge_sequence('links', ServiceLink.parse)
md.merge_sequence('secrets', types.ServiceSecret.parse)
for field in ['volumes', 'devices']: for field in ['volumes', 'devices']:
md.merge_field(field, merge_path_mappings) md.merge_field(field, merge_path_mappings)

View File

@ -0,0 +1,428 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "config_schema_v3.1.json",
"type": "object",
"required": ["version"],
"properties": {
"version": {
"type": "string"
},
"services": {
"id": "#/properties/services",
"type": "object",
"patternProperties": {
"^[a-zA-Z0-9._-]+$": {
"$ref": "#/definitions/service"
}
},
"additionalProperties": false
},
"networks": {
"id": "#/properties/networks",
"type": "object",
"patternProperties": {
"^[a-zA-Z0-9._-]+$": {
"$ref": "#/definitions/network"
}
}
},
"volumes": {
"id": "#/properties/volumes",
"type": "object",
"patternProperties": {
"^[a-zA-Z0-9._-]+$": {
"$ref": "#/definitions/volume"
}
},
"additionalProperties": false
},
"secrets": {
"id": "#/properties/secrets",
"type": "object",
"patternProperties": {
"^[a-zA-Z0-9._-]+$": {
"$ref": "#/definitions/secret"
}
},
"additionalProperties": false
}
},
"additionalProperties": false,
"definitions": {
"service": {
"id": "#/definitions/service",
"type": "object",
"properties": {
"deploy": {"$ref": "#/definitions/deployment"},
"build": {
"oneOf": [
{"type": "string"},
{
"type": "object",
"properties": {
"context": {"type": "string"},
"dockerfile": {"type": "string"},
"args": {"$ref": "#/definitions/list_or_dict"}
},
"additionalProperties": false
}
]
},
"cap_add": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
"cap_drop": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
"cgroup_parent": {"type": "string"},
"command": {
"oneOf": [
{"type": "string"},
{"type": "array", "items": {"type": "string"}}
]
},
"container_name": {"type": "string"},
"depends_on": {"$ref": "#/definitions/list_of_strings"},
"devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
"dns": {"$ref": "#/definitions/string_or_list"},
"dns_search": {"$ref": "#/definitions/string_or_list"},
"domainname": {"type": "string"},
"entrypoint": {
"oneOf": [
{"type": "string"},
{"type": "array", "items": {"type": "string"}}
]
},
"env_file": {"$ref": "#/definitions/string_or_list"},
"environment": {"$ref": "#/definitions/list_or_dict"},
"expose": {
"type": "array",
"items": {
"type": ["string", "number"],
"format": "expose"
},
"uniqueItems": true
},
"external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
"extra_hosts": {"$ref": "#/definitions/list_or_dict"},
"healthcheck": {"$ref": "#/definitions/healthcheck"},
"hostname": {"type": "string"},
"image": {"type": "string"},
"ipc": {"type": "string"},
"labels": {"$ref": "#/definitions/list_or_dict"},
"links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
"logging": {
"type": "object",
"properties": {
"driver": {"type": "string"},
"options": {
"type": "object",
"patternProperties": {
"^.+$": {"type": ["string", "number", "null"]}
}
}
},
"additionalProperties": false
},
"mac_address": {"type": "string"},
"network_mode": {"type": "string"},
"networks": {
"oneOf": [
{"$ref": "#/definitions/list_of_strings"},
{
"type": "object",
"patternProperties": {
"^[a-zA-Z0-9._-]+$": {
"oneOf": [
{
"type": "object",
"properties": {
"aliases": {"$ref": "#/definitions/list_of_strings"},
"ipv4_address": {"type": "string"},
"ipv6_address": {"type": "string"}
},
"additionalProperties": false
},
{"type": "null"}
]
}
},
"additionalProperties": false
}
]
},
"pid": {"type": ["string", "null"]},
"ports": {
"type": "array",
"items": {
"type": ["string", "number"],
"format": "ports"
},
"uniqueItems": true
},
"privileged": {"type": "boolean"},
"read_only": {"type": "boolean"},
"restart": {"type": "string"},
"security_opt": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
"shm_size": {"type": ["number", "string"]},
"secrets": {
"type": "array",
"items": {
"oneOf": [
{"type": "string"},
{
"type": "object",
"properties": {
"source": {"type": "string"},
"target": {"type": "string"},
"uid": {"type": "string"},
"gid": {"type": "string"},
"mode": {"type": "number"}
}
}
]
}
},
"sysctls": {"$ref": "#/definitions/list_or_dict"},
"stdin_open": {"type": "boolean"},
"stop_grace_period": {"type": "string", "format": "duration"},
"stop_signal": {"type": "string"},
"tmpfs": {"$ref": "#/definitions/string_or_list"},
"tty": {"type": "boolean"},
"ulimits": {
"type": "object",
"patternProperties": {
"^[a-z]+$": {
"oneOf": [
{"type": "integer"},
{
"type":"object",
"properties": {
"hard": {"type": "integer"},
"soft": {"type": "integer"}
},
"required": ["soft", "hard"],
"additionalProperties": false
}
]
}
}
},
"user": {"type": "string"},
"userns_mode": {"type": "string"},
"volumes": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
"working_dir": {"type": "string"}
},
"additionalProperties": false
},
"healthcheck": {
"id": "#/definitions/healthcheck",
"type": "object",
"additionalProperties": false,
"properties": {
"disable": {"type": "boolean"},
"interval": {"type": "string"},
"retries": {"type": "number"},
"test": {
"oneOf": [
{"type": "string"},
{"type": "array", "items": {"type": "string"}}
]
},
"timeout": {"type": "string"}
}
},
"deployment": {
"id": "#/definitions/deployment",
"type": ["object", "null"],
"properties": {
"mode": {"type": "string"},
"replicas": {"type": "integer"},
"labels": {"$ref": "#/definitions/list_or_dict"},
"update_config": {
"type": "object",
"properties": {
"parallelism": {"type": "integer"},
"delay": {"type": "string", "format": "duration"},
"failure_action": {"type": "string"},
"monitor": {"type": "string", "format": "duration"},
"max_failure_ratio": {"type": "number"}
},
"additionalProperties": false
},
"resources": {
"type": "object",
"properties": {
"limits": {"$ref": "#/definitions/resource"},
"reservations": {"$ref": "#/definitions/resource"}
}
},
"restart_policy": {
"type": "object",
"properties": {
"condition": {"type": "string"},
"delay": {"type": "string", "format": "duration"},
"max_attempts": {"type": "integer"},
"window": {"type": "string", "format": "duration"}
},
"additionalProperties": false
},
"placement": {
"type": "object",
"properties": {
"constraints": {"type": "array", "items": {"type": "string"}}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"resource": {
"id": "#/definitions/resource",
"type": "object",
"properties": {
"cpus": {"type": "string"},
"memory": {"type": "string"}
},
"additionalProperties": false
},
"network": {
"id": "#/definitions/network",
"type": ["object", "null"],
"properties": {
"driver": {"type": "string"},
"driver_opts": {
"type": "object",
"patternProperties": {
"^.+$": {"type": ["string", "number"]}
}
},
"ipam": {
"type": "object",
"properties": {
"driver": {"type": "string"},
"config": {
"type": "array",
"items": {
"type": "object",
"properties": {
"subnet": {"type": "string"}
},
"additionalProperties": false
}
}
},
"additionalProperties": false
},
"external": {
"type": ["boolean", "object"],
"properties": {
"name": {"type": "string"}
},
"additionalProperties": false
},
"internal": {"type": "boolean"},
"labels": {"$ref": "#/definitions/list_or_dict"}
},
"additionalProperties": false
},
"volume": {
"id": "#/definitions/volume",
"type": ["object", "null"],
"properties": {
"driver": {"type": "string"},
"driver_opts": {
"type": "object",
"patternProperties": {
"^.+$": {"type": ["string", "number"]}
}
},
"external": {
"type": ["boolean", "object"],
"properties": {
"name": {"type": "string"}
},
"additionalProperties": false
},
"labels": {"$ref": "#/definitions/list_or_dict"}
},
"additionalProperties": false
},
"secret": {
"id": "#/definitions/secret",
"type": "object",
"properties": {
"file": {"type": "string"},
"external": {
"type": ["boolean", "object"],
"properties": {
"name": {"type": "string"}
}
},
"labels": {"$ref": "#/definitions/list_or_dict"}
},
"additionalProperties": false
},
"string_or_list": {
"oneOf": [
{"type": "string"},
{"$ref": "#/definitions/list_of_strings"}
]
},
"list_of_strings": {
"type": "array",
"items": {"type": "string"},
"uniqueItems": true
},
"list_or_dict": {
"oneOf": [
{
"type": "object",
"patternProperties": {
".+": {
"type": ["string", "number", "null"]
}
},
"additionalProperties": false
},
{"type": "array", "items": {"type": "string"}, "uniqueItems": true}
]
},
"constraints": {
"service": {
"id": "#/definitions/constraints/service",
"anyOf": [
{"required": ["build"]},
{"required": ["image"]}
],
"properties": {
"build": {
"required": ["context"]
}
}
}
}
}
}

View File

@ -10,8 +10,8 @@ from collections import namedtuple
import six import six
from compose.config.config import V1 from ..const import COMPOSEFILE_V1 as V1
from compose.config.errors import ConfigurationError from .errors import ConfigurationError
from compose.const import IS_WINDOWS_PLATFORM from compose.const import IS_WINDOWS_PLATFORM
from compose.utils import splitdrive from compose.utils import splitdrive
@ -234,3 +234,22 @@ class ServiceLink(namedtuple('_ServiceLink', 'target alias')):
@property @property
def merge_field(self): def merge_field(self):
return self.alias return self.alias
class ServiceSecret(namedtuple('_ServiceSecret', 'source target uid gid mode')):
@classmethod
def parse(cls, spec):
if isinstance(spec, six.string_types):
return cls(spec, None, None, None, None)
return cls(
spec.get('source'),
spec.get('target'),
spec.get('uid'),
spec.get('gid'),
spec.get('mode'),
)
@property
def merge_field(self):
return self.source

View File

@ -16,16 +16,20 @@ LABEL_VERSION = 'com.docker.compose.version'
LABEL_VOLUME = 'com.docker.compose.volume' LABEL_VOLUME = 'com.docker.compose.volume'
LABEL_CONFIG_HASH = 'com.docker.compose.config-hash' LABEL_CONFIG_HASH = 'com.docker.compose.config-hash'
SECRETS_PATH = '/run/secrets'
COMPOSEFILE_V1 = '1' COMPOSEFILE_V1 = '1'
COMPOSEFILE_V2_0 = '2.0' COMPOSEFILE_V2_0 = '2.0'
COMPOSEFILE_V2_1 = '2.1' COMPOSEFILE_V2_1 = '2.1'
COMPOSEFILE_V3_0 = '3.0' COMPOSEFILE_V3_0 = '3.0'
COMPOSEFILE_V3_1 = '3.1'
API_VERSIONS = { API_VERSIONS = {
COMPOSEFILE_V1: '1.21', COMPOSEFILE_V1: '1.21',
COMPOSEFILE_V2_0: '1.22', COMPOSEFILE_V2_0: '1.22',
COMPOSEFILE_V2_1: '1.24', COMPOSEFILE_V2_1: '1.24',
COMPOSEFILE_V3_0: '1.25', COMPOSEFILE_V3_0: '1.25',
COMPOSEFILE_V3_1: '1.25',
} }
API_VERSION_TO_ENGINE_VERSION = { API_VERSION_TO_ENGINE_VERSION = {
@ -33,4 +37,5 @@ API_VERSION_TO_ENGINE_VERSION = {
API_VERSIONS[COMPOSEFILE_V2_0]: '1.10.0', API_VERSIONS[COMPOSEFILE_V2_0]: '1.10.0',
API_VERSIONS[COMPOSEFILE_V2_1]: '1.12.0', API_VERSIONS[COMPOSEFILE_V2_1]: '1.12.0',
API_VERSIONS[COMPOSEFILE_V3_0]: '1.13.0', API_VERSIONS[COMPOSEFILE_V3_0]: '1.13.0',
API_VERSIONS[COMPOSEFILE_V3_1]: '1.13.0',
} }

View File

@ -104,6 +104,11 @@ class Project(object):
for volume_spec in service_dict.get('volumes', []) for volume_spec in service_dict.get('volumes', [])
] ]
secrets = get_secrets(
service_dict['name'],
service_dict.pop('secrets', None) or [],
config_data.secrets)
project.services.append( project.services.append(
Service( Service(
service_dict.pop('name'), service_dict.pop('name'),
@ -114,6 +119,7 @@ class Project(object):
links=links, links=links,
network_mode=network_mode, network_mode=network_mode,
volumes_from=volumes_from, volumes_from=volumes_from,
secrets=secrets,
**service_dict) **service_dict)
) )
@ -553,6 +559,33 @@ def get_volumes_from(project, service_dict):
return [build_volume_from(vf) for vf in volumes_from] return [build_volume_from(vf) for vf in volumes_from]
def get_secrets(service, service_secrets, secret_defs):
secrets = []
for secret in service_secrets:
secret_def = secret_defs.get(secret.source)
if not secret_def:
raise ConfigurationError(
"Service \"{service}\" uses an undefined secret \"{secret}\" "
.format(service=service, secret=secret.source))
if secret_def.get('external_name'):
log.warn("Service \"{service}\" uses secret \"{secret}\" which is external. "
"External secrets are not available to containers created by "
"docker-compose.".format(service=service, secret=secret.source))
continue
if secret.uid or secret.gid or secret.mode:
log.warn("Service \"{service}\" uses secret \"{secret}\" with uid, "
"gid, or mode. These fields are not supported by this "
"implementation of the Compose file".format(
service=service, secret=secret.source))
secrets.append({'secret': secret, 'file': secret_def.get('file')})
return secrets
def warn_for_swarm_mode(client): def warn_for_swarm_mode(client):
info = client.info() info = client.info()
if info.get('Swarm', {}).get('LocalNodeState') == 'active': if info.get('Swarm', {}).get('LocalNodeState') == 'active':

View File

@ -17,6 +17,7 @@ from docker.utils.ports import build_port_bindings
from docker.utils.ports import split_port from docker.utils.ports import split_port
from . import __version__ from . import __version__
from . import const
from . import progress_stream from . import progress_stream
from .config import DOCKER_CONFIG_KEYS from .config import DOCKER_CONFIG_KEYS
from .config import merge_environment from .config import merge_environment
@ -139,6 +140,7 @@ class Service(object):
volumes_from=None, volumes_from=None,
network_mode=None, network_mode=None,
networks=None, networks=None,
secrets=None,
**options **options
): ):
self.name = name self.name = name
@ -149,6 +151,7 @@ class Service(object):
self.volumes_from = volumes_from or [] self.volumes_from = volumes_from or []
self.network_mode = network_mode or NetworkMode(None) self.network_mode = network_mode or NetworkMode(None)
self.networks = networks or {} self.networks = networks or {}
self.secrets = secrets or []
self.options = options self.options = options
def __repr__(self): def __repr__(self):
@ -692,9 +695,14 @@ class Service(object):
override_options['binds'] = binds override_options['binds'] = binds
container_options['environment'].update(affinity) container_options['environment'].update(affinity)
if 'volumes' in container_options: container_options['volumes'] = dict(
container_options['volumes'] = dict( (v.internal, {}) for v in container_options.get('volumes') or {})
(v.internal, {}) for v in container_options['volumes'])
secret_volumes = self.get_secret_volumes()
if secret_volumes:
override_options['binds'].extend(v.repr() for v in secret_volumes)
container_options['volumes'].update(
(v.internal, {}) for v in secret_volumes)
container_options['image'] = self.image_name container_options['image'] = self.image_name
@ -765,6 +773,15 @@ class Service(object):
return host_config return host_config
def get_secret_volumes(self):
def build_spec(secret):
target = '{}/{}'.format(
const.SECRETS_PATH,
secret['secret'].target or secret['secret'].source)
return VolumeSpec(secret['file'], target, 'ro')
return [build_spec(secret) for secret in self.secrets]
def build(self, no_cache=False, pull=False, force_rm=False): def build(self, no_cache=False, pull=False, force_rm=False):
log.info('Building %s' % self.name) log.info('Building %s' % self.name)

1
tests/fixtures/secrets/default vendored Normal file
View File

@ -0,0 +1 @@
This is the secret

View File

@ -1,6 +1,7 @@
from __future__ import absolute_import from __future__ import absolute_import
from __future__ import unicode_literals from __future__ import unicode_literals
import os.path
import random import random
import py import py
@ -8,12 +9,14 @@ import pytest
from docker.errors import NotFound from docker.errors import NotFound
from .. import mock from .. import mock
from ..helpers import build_config from ..helpers import build_config as load_config
from .testcases import DockerClientTestCase from .testcases import DockerClientTestCase
from compose.config import config from compose.config import config
from compose.config import ConfigurationError from compose.config import ConfigurationError
from compose.config import types
from compose.config.config import V2_0 from compose.config.config import V2_0
from compose.config.config import V2_1 from compose.config.config import V2_1
from compose.config.config import V3_1
from compose.config.types import VolumeFromSpec from compose.config.types import VolumeFromSpec
from compose.config.types import VolumeSpec from compose.config.types import VolumeSpec
from compose.const import LABEL_PROJECT from compose.const import LABEL_PROJECT
@ -26,6 +29,16 @@ from compose.project import ProjectError
from compose.service import ConvergenceStrategy from compose.service import ConvergenceStrategy
from tests.integration.testcases import v2_1_only from tests.integration.testcases import v2_1_only
from tests.integration.testcases import v2_only from tests.integration.testcases import v2_only
from tests.integration.testcases import v3_only
def build_config(**kwargs):
return config.Config(
version=kwargs.get('version'),
services=kwargs.get('services'),
volumes=kwargs.get('volumes'),
networks=kwargs.get('networks'),
secrets=kwargs.get('secrets'))
class ProjectTest(DockerClientTestCase): class ProjectTest(DockerClientTestCase):
@ -70,7 +83,7 @@ class ProjectTest(DockerClientTestCase):
def test_volumes_from_service(self): def test_volumes_from_service(self):
project = Project.from_config( project = Project.from_config(
name='composetest', name='composetest',
config_data=build_config({ config_data=load_config({
'data': { 'data': {
'image': 'busybox:latest', 'image': 'busybox:latest',
'volumes': ['/var/data'], 'volumes': ['/var/data'],
@ -96,7 +109,7 @@ class ProjectTest(DockerClientTestCase):
) )
project = Project.from_config( project = Project.from_config(
name='composetest', name='composetest',
config_data=build_config({ config_data=load_config({
'db': { 'db': {
'image': 'busybox:latest', 'image': 'busybox:latest',
'volumes_from': ['composetest_data_container'], 'volumes_from': ['composetest_data_container'],
@ -112,7 +125,7 @@ class ProjectTest(DockerClientTestCase):
project = Project.from_config( project = Project.from_config(
name='composetest', name='composetest',
client=self.client, client=self.client,
config_data=build_config({ config_data=load_config({
'version': V2_0, 'version': V2_0,
'services': { 'services': {
'net': { 'net': {
@ -139,7 +152,7 @@ class ProjectTest(DockerClientTestCase):
def get_project(): def get_project():
return Project.from_config( return Project.from_config(
name='composetest', name='composetest',
config_data=build_config({ config_data=load_config({
'version': V2_0, 'version': V2_0,
'services': { 'services': {
'web': { 'web': {
@ -174,7 +187,7 @@ class ProjectTest(DockerClientTestCase):
def test_net_from_service_v1(self): def test_net_from_service_v1(self):
project = Project.from_config( project = Project.from_config(
name='composetest', name='composetest',
config_data=build_config({ config_data=load_config({
'net': { 'net': {
'image': 'busybox:latest', 'image': 'busybox:latest',
'command': ["top"] 'command': ["top"]
@ -198,7 +211,7 @@ class ProjectTest(DockerClientTestCase):
def get_project(): def get_project():
return Project.from_config( return Project.from_config(
name='composetest', name='composetest',
config_data=build_config({ config_data=load_config({
'web': { 'web': {
'image': 'busybox:latest', 'image': 'busybox:latest',
'net': 'container:composetest_net_container' 'net': 'container:composetest_net_container'
@ -469,7 +482,7 @@ class ProjectTest(DockerClientTestCase):
def test_project_up_starts_depends(self): def test_project_up_starts_depends(self):
project = Project.from_config( project = Project.from_config(
name='composetest', name='composetest',
config_data=build_config({ config_data=load_config({
'console': { 'console': {
'image': 'busybox:latest', 'image': 'busybox:latest',
'command': ["top"], 'command': ["top"],
@ -504,7 +517,7 @@ class ProjectTest(DockerClientTestCase):
def test_project_up_with_no_deps(self): def test_project_up_with_no_deps(self):
project = Project.from_config( project = Project.from_config(
name='composetest', name='composetest',
config_data=build_config({ config_data=load_config({
'console': { 'console': {
'image': 'busybox:latest', 'image': 'busybox:latest',
'command': ["top"], 'command': ["top"],
@ -564,7 +577,7 @@ class ProjectTest(DockerClientTestCase):
@v2_only() @v2_only()
def test_project_up_networks(self): def test_project_up_networks(self):
config_data = config.Config( config_data = build_config(
version=V2_0, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
@ -576,7 +589,6 @@ class ProjectTest(DockerClientTestCase):
'baz': {'aliases': ['extra']}, 'baz': {'aliases': ['extra']},
}, },
}], }],
volumes={},
networks={ networks={
'foo': {'driver': 'bridge'}, 'foo': {'driver': 'bridge'},
'bar': {'driver': None}, 'bar': {'driver': None},
@ -610,14 +622,13 @@ class ProjectTest(DockerClientTestCase):
@v2_only() @v2_only()
def test_up_with_ipam_config(self): def test_up_with_ipam_config(self):
config_data = config.Config( config_data = build_config(
version=V2_0, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
'image': 'busybox:latest', 'image': 'busybox:latest',
'networks': {'front': None}, 'networks': {'front': None},
}], }],
volumes={},
networks={ networks={
'front': { 'front': {
'driver': 'bridge', 'driver': 'bridge',
@ -671,7 +682,7 @@ class ProjectTest(DockerClientTestCase):
@v2_only() @v2_only()
def test_up_with_network_static_addresses(self): def test_up_with_network_static_addresses(self):
config_data = config.Config( config_data = build_config(
version=V2_0, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
@ -684,7 +695,6 @@ class ProjectTest(DockerClientTestCase):
} }
}, },
}], }],
volumes={},
networks={ networks={
'static_test': { 'static_test': {
'driver': 'bridge', 'driver': 'bridge',
@ -726,7 +736,7 @@ class ProjectTest(DockerClientTestCase):
@v2_1_only() @v2_1_only()
def test_up_with_enable_ipv6(self): def test_up_with_enable_ipv6(self):
self.require_api_version('1.23') self.require_api_version('1.23')
config_data = config.Config( config_data = build_config(
version=V2_0, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
@ -738,7 +748,6 @@ class ProjectTest(DockerClientTestCase):
} }
}, },
}], }],
volumes={},
networks={ networks={
'static_test': { 'static_test': {
'driver': 'bridge', 'driver': 'bridge',
@ -770,7 +779,7 @@ class ProjectTest(DockerClientTestCase):
@v2_only() @v2_only()
def test_up_with_network_static_addresses_missing_subnet(self): def test_up_with_network_static_addresses_missing_subnet(self):
config_data = config.Config( config_data = build_config(
version=V2_0, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
@ -782,7 +791,6 @@ class ProjectTest(DockerClientTestCase):
} }
}, },
}], }],
volumes={},
networks={ networks={
'static_test': { 'static_test': {
'driver': 'bridge', 'driver': 'bridge',
@ -807,7 +815,7 @@ class ProjectTest(DockerClientTestCase):
@v2_1_only() @v2_1_only()
def test_up_with_network_link_local_ips(self): def test_up_with_network_link_local_ips(self):
config_data = config.Config( config_data = build_config(
version=V2_1, version=V2_1,
services=[{ services=[{
'name': 'web', 'name': 'web',
@ -818,7 +826,6 @@ class ProjectTest(DockerClientTestCase):
} }
} }
}], }],
volumes={},
networks={ networks={
'linklocaltest': {'driver': 'bridge'} 'linklocaltest': {'driver': 'bridge'}
} }
@ -844,15 +851,13 @@ class ProjectTest(DockerClientTestCase):
@v2_1_only() @v2_1_only()
def test_up_with_isolation(self): def test_up_with_isolation(self):
self.require_api_version('1.24') self.require_api_version('1.24')
config_data = config.Config( config_data = build_config(
version=V2_1, version=V2_1,
services=[{ services=[{
'name': 'web', 'name': 'web',
'image': 'busybox:latest', 'image': 'busybox:latest',
'isolation': 'default' 'isolation': 'default'
}], }],
volumes={},
networks={}
) )
project = Project.from_config( project = Project.from_config(
client=self.client, client=self.client,
@ -866,15 +871,13 @@ class ProjectTest(DockerClientTestCase):
@v2_1_only() @v2_1_only()
def test_up_with_invalid_isolation(self): def test_up_with_invalid_isolation(self):
self.require_api_version('1.24') self.require_api_version('1.24')
config_data = config.Config( config_data = build_config(
version=V2_1, version=V2_1,
services=[{ services=[{
'name': 'web', 'name': 'web',
'image': 'busybox:latest', 'image': 'busybox:latest',
'isolation': 'foobar' 'isolation': 'foobar'
}], }],
volumes={},
networks={}
) )
project = Project.from_config( project = Project.from_config(
client=self.client, client=self.client,
@ -887,14 +890,13 @@ class ProjectTest(DockerClientTestCase):
@v2_only() @v2_only()
def test_project_up_with_network_internal(self): def test_project_up_with_network_internal(self):
self.require_api_version('1.23') self.require_api_version('1.23')
config_data = config.Config( config_data = build_config(
version=V2_0, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
'image': 'busybox:latest', 'image': 'busybox:latest',
'networks': {'internal': None}, 'networks': {'internal': None},
}], }],
volumes={},
networks={ networks={
'internal': {'driver': 'bridge', 'internal': True}, 'internal': {'driver': 'bridge', 'internal': True},
}, },
@ -917,14 +919,13 @@ class ProjectTest(DockerClientTestCase):
network_name = 'network_with_label' network_name = 'network_with_label'
config_data = config.Config( config_data = build_config(
version=V2_0, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
'image': 'busybox:latest', 'image': 'busybox:latest',
'networks': {network_name: None} 'networks': {network_name: None}
}], }],
volumes={},
networks={ networks={
network_name: {'labels': {'label_key': 'label_val'}} network_name: {'labels': {'label_key': 'label_val'}}
} }
@ -951,7 +952,7 @@ class ProjectTest(DockerClientTestCase):
def test_project_up_volumes(self): def test_project_up_volumes(self):
vol_name = '{0:x}'.format(random.getrandbits(32)) vol_name = '{0:x}'.format(random.getrandbits(32))
full_vol_name = 'composetest_{0}'.format(vol_name) full_vol_name = 'composetest_{0}'.format(vol_name)
config_data = config.Config( config_data = build_config(
version=V2_0, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
@ -959,7 +960,6 @@ class ProjectTest(DockerClientTestCase):
'command': 'top' 'command': 'top'
}], }],
volumes={vol_name: {'driver': 'local'}}, volumes={vol_name: {'driver': 'local'}},
networks={},
) )
project = Project.from_config( project = Project.from_config(
@ -979,7 +979,7 @@ class ProjectTest(DockerClientTestCase):
volume_name = 'volume_with_label' volume_name = 'volume_with_label'
config_data = config.Config( config_data = build_config(
version=V2_0, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
@ -993,7 +993,6 @@ class ProjectTest(DockerClientTestCase):
} }
} }
}, },
networks={},
) )
project = Project.from_config( project = Project.from_config(
@ -1106,7 +1105,7 @@ class ProjectTest(DockerClientTestCase):
def test_initialize_volumes(self): def test_initialize_volumes(self):
vol_name = '{0:x}'.format(random.getrandbits(32)) vol_name = '{0:x}'.format(random.getrandbits(32))
full_vol_name = 'composetest_{0}'.format(vol_name) full_vol_name = 'composetest_{0}'.format(vol_name)
config_data = config.Config( config_data = build_config(
version=V2_0, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
@ -1114,7 +1113,6 @@ class ProjectTest(DockerClientTestCase):
'command': 'top' 'command': 'top'
}], }],
volumes={vol_name: {}}, volumes={vol_name: {}},
networks={},
) )
project = Project.from_config( project = Project.from_config(
@ -1124,14 +1122,14 @@ class ProjectTest(DockerClientTestCase):
project.volumes.initialize() project.volumes.initialize()
volume_data = self.client.inspect_volume(full_vol_name) volume_data = self.client.inspect_volume(full_vol_name)
self.assertEqual(volume_data['Name'], full_vol_name) assert volume_data['Name'] == full_vol_name
self.assertEqual(volume_data['Driver'], 'local') assert volume_data['Driver'] == 'local'
@v2_only() @v2_only()
def test_project_up_implicit_volume_driver(self): def test_project_up_implicit_volume_driver(self):
vol_name = '{0:x}'.format(random.getrandbits(32)) vol_name = '{0:x}'.format(random.getrandbits(32))
full_vol_name = 'composetest_{0}'.format(vol_name) full_vol_name = 'composetest_{0}'.format(vol_name)
config_data = config.Config( config_data = build_config(
version=V2_0, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
@ -1139,7 +1137,6 @@ class ProjectTest(DockerClientTestCase):
'command': 'top' 'command': 'top'
}], }],
volumes={vol_name: {}}, volumes={vol_name: {}},
networks={},
) )
project = Project.from_config( project = Project.from_config(
@ -1152,11 +1149,47 @@ class ProjectTest(DockerClientTestCase):
self.assertEqual(volume_data['Name'], full_vol_name) self.assertEqual(volume_data['Name'], full_vol_name)
self.assertEqual(volume_data['Driver'], 'local') self.assertEqual(volume_data['Driver'], 'local')
@v3_only()
def test_project_up_with_secrets(self):
create_host_file(self.client, os.path.abspath('tests/fixtures/secrets/default'))
config_data = build_config(
version=V3_1,
services=[{
'name': 'web',
'image': 'busybox:latest',
'command': 'cat /run/secrets/special',
'secrets': [
types.ServiceSecret.parse({'source': 'super', 'target': 'special'}),
],
}],
secrets={
'super': {
'file': os.path.abspath('tests/fixtures/secrets/default'),
},
},
)
project = Project.from_config(
client=self.client,
name='composetest',
config_data=config_data,
)
project.up()
project.stop()
containers = project.containers(stopped=True)
assert len(containers) == 1
container, = containers
output = container.logs()
assert output == b"This is the secret\n"
@v2_only() @v2_only()
def test_initialize_volumes_invalid_volume_driver(self): def test_initialize_volumes_invalid_volume_driver(self):
vol_name = '{0:x}'.format(random.getrandbits(32)) vol_name = '{0:x}'.format(random.getrandbits(32))
config_data = config.Config( config_data = build_config(
version=V2_0, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
@ -1164,7 +1197,6 @@ class ProjectTest(DockerClientTestCase):
'command': 'top' 'command': 'top'
}], }],
volumes={vol_name: {'driver': 'foobar'}}, volumes={vol_name: {'driver': 'foobar'}},
networks={},
) )
project = Project.from_config( project = Project.from_config(
@ -1179,7 +1211,7 @@ class ProjectTest(DockerClientTestCase):
vol_name = '{0:x}'.format(random.getrandbits(32)) vol_name = '{0:x}'.format(random.getrandbits(32))
full_vol_name = 'composetest_{0}'.format(vol_name) full_vol_name = 'composetest_{0}'.format(vol_name)
config_data = config.Config( config_data = build_config(
version=V2_0, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
@ -1187,7 +1219,6 @@ class ProjectTest(DockerClientTestCase):
'command': 'top' 'command': 'top'
}], }],
volumes={vol_name: {'driver': 'local'}}, volumes={vol_name: {'driver': 'local'}},
networks={},
) )
project = Project.from_config( project = Project.from_config(
name='composetest', name='composetest',
@ -1218,7 +1249,7 @@ class ProjectTest(DockerClientTestCase):
vol_name = '{0:x}'.format(random.getrandbits(32)) vol_name = '{0:x}'.format(random.getrandbits(32))
full_vol_name = 'composetest_{0}'.format(vol_name) full_vol_name = 'composetest_{0}'.format(vol_name)
config_data = config.Config( config_data = build_config(
version=V2_0, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
@ -1226,7 +1257,6 @@ class ProjectTest(DockerClientTestCase):
'command': 'top' 'command': 'top'
}], }],
volumes={vol_name: {'driver': 'local'}}, volumes={vol_name: {'driver': 'local'}},
networks={},
) )
project = Project.from_config( project = Project.from_config(
name='composetest', name='composetest',
@ -1257,7 +1287,7 @@ class ProjectTest(DockerClientTestCase):
vol_name = 'composetest_{0:x}'.format(random.getrandbits(32)) vol_name = 'composetest_{0:x}'.format(random.getrandbits(32))
full_vol_name = 'composetest_{0}'.format(vol_name) full_vol_name = 'composetest_{0}'.format(vol_name)
self.client.create_volume(vol_name) self.client.create_volume(vol_name)
config_data = config.Config( config_data = build_config(
version=V2_0, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
@ -1267,7 +1297,6 @@ class ProjectTest(DockerClientTestCase):
volumes={ volumes={
vol_name: {'external': True, 'external_name': vol_name} vol_name: {'external': True, 'external_name': vol_name}
}, },
networks=None,
) )
project = Project.from_config( project = Project.from_config(
name='composetest', name='composetest',
@ -1282,7 +1311,7 @@ class ProjectTest(DockerClientTestCase):
def test_initialize_volumes_inexistent_external_volume(self): def test_initialize_volumes_inexistent_external_volume(self):
vol_name = '{0:x}'.format(random.getrandbits(32)) vol_name = '{0:x}'.format(random.getrandbits(32))
config_data = config.Config( config_data = build_config(
version=V2_0, version=V2_0,
services=[{ services=[{
'name': 'web', 'name': 'web',
@ -1292,7 +1321,6 @@ class ProjectTest(DockerClientTestCase):
volumes={ volumes={
vol_name: {'external': True, 'external_name': vol_name} vol_name: {'external': True, 'external_name': vol_name}
}, },
networks=None,
) )
project = Project.from_config( project = Project.from_config(
name='composetest', name='composetest',
@ -1349,7 +1377,7 @@ class ProjectTest(DockerClientTestCase):
} }
} }
config_data = build_config(config_dict) config_data = load_config(config_dict)
project = Project.from_config( project = Project.from_config(
name='composetest', config_data=config_data, client=self.client name='composetest', config_data=config_data, client=self.client
) )
@ -1357,7 +1385,7 @@ class ProjectTest(DockerClientTestCase):
config_dict['service2'] = config_dict['service1'] config_dict['service2'] = config_dict['service1']
del config_dict['service1'] del config_dict['service1']
config_data = build_config(config_dict) config_data = load_config(config_dict)
project = Project.from_config( project = Project.from_config(
name='composetest', config_data=config_data, client=self.client name='composetest', config_data=config_data, client=self.client
) )
@ -1402,7 +1430,7 @@ class ProjectTest(DockerClientTestCase):
} }
} }
} }
config_data = build_config(config_dict) config_data = load_config(config_dict)
project = Project.from_config( project = Project.from_config(
name='composetest', config_data=config_data, client=self.client name='composetest', config_data=config_data, client=self.client
) )
@ -1439,7 +1467,7 @@ class ProjectTest(DockerClientTestCase):
} }
} }
} }
config_data = build_config(config_dict) config_data = load_config(config_dict)
project = Project.from_config( project = Project.from_config(
name='composetest', config_data=config_data, client=self.client name='composetest', config_data=config_data, client=self.client
) )
@ -1475,7 +1503,7 @@ class ProjectTest(DockerClientTestCase):
} }
} }
} }
config_data = build_config(config_dict) config_data = load_config(config_dict)
project = Project.from_config( project = Project.from_config(
name='composetest', config_data=config_data, client=self.client name='composetest', config_data=config_data, client=self.client
) )
@ -1489,3 +1517,30 @@ class ProjectTest(DockerClientTestCase):
assert 'svc1' in svc2.get_dependency_names() assert 'svc1' in svc2.get_dependency_names()
with pytest.raises(NoHealthCheckConfigured): with pytest.raises(NoHealthCheckConfigured):
svc1.is_healthy() svc1.is_healthy()
def create_host_file(client, filename):
dirname = os.path.dirname(filename)
with open(filename, 'r') as fh:
content = fh.read()
container = client.create_container(
'busybox:latest',
['sh', '-c', 'echo -n "{}" > {}'.format(content, filename)],
volumes={dirname: {}},
host_config=client.create_host_config(
binds={dirname: {'bind': dirname, 'ro': False}},
network_mode='none',
),
)
try:
client.start(container)
exitcode = client.wait(container)
if exitcode != 0:
output = client.logs(container)
raise Exception(
"Container exited with code {}:\n{}".format(exitcode, output))
finally:
client.remove_container(container, force=True)

View File

@ -41,9 +41,9 @@ def engine_max_version():
version = os.environ['DOCKER_VERSION'].partition('-')[0] version = os.environ['DOCKER_VERSION'].partition('-')[0]
if version_lt(version, '1.10'): if version_lt(version, '1.10'):
return V1 return V1
elif version_lt(version, '1.12'): if version_lt(version, '1.12'):
return V2_0 return V2_0
elif version_lt(version, '1.13'): if version_lt(version, '1.13'):
return V2_1 return V2_1
return V3_0 return V3_0
@ -52,8 +52,9 @@ def build_version_required_decorator(ignored_versions):
def decorator(f): def decorator(f):
@functools.wraps(f) @functools.wraps(f)
def wrapper(self, *args, **kwargs): def wrapper(self, *args, **kwargs):
if engine_max_version() in ignored_versions: max_version = engine_max_version()
skip("Engine version is too low") if max_version in ignored_versions:
skip("Engine version %s is too low" % max_version)
return return
return f(self, *args, **kwargs) return f(self, *args, **kwargs)
return wrapper return wrapper

View File

@ -77,7 +77,8 @@ def test_to_bundle():
version=2, version=2,
services=services, services=services,
volumes={'special': {}}, volumes={'special': {}},
networks={'extra': {}}) networks={'extra': {}},
secrets={})
with mock.patch('compose.bundle.log.warn', autospec=True) as mock_log: with mock.patch('compose.bundle.log.warn', autospec=True) as mock_log:
output = bundle.to_bundle(config, image_digests) output = bundle.to_bundle(config, image_digests)

View File

@ -36,6 +36,7 @@ class ProjectTest(unittest.TestCase):
], ],
networks=None, networks=None,
volumes=None, volumes=None,
secrets=None,
) )
project = Project.from_config( project = Project.from_config(
name='composetest', name='composetest',
@ -64,6 +65,7 @@ class ProjectTest(unittest.TestCase):
], ],
networks=None, networks=None,
volumes=None, volumes=None,
secrets=None,
) )
project = Project.from_config('composetest', config, None) project = Project.from_config('composetest', config, None)
self.assertEqual(len(project.services), 2) self.assertEqual(len(project.services), 2)
@ -170,6 +172,7 @@ class ProjectTest(unittest.TestCase):
}], }],
networks=None, networks=None,
volumes=None, volumes=None,
secrets=None,
), ),
) )
assert project.get_service('test')._get_volumes_from() == [container_id + ":rw"] assert project.get_service('test')._get_volumes_from() == [container_id + ":rw"]
@ -202,6 +205,7 @@ class ProjectTest(unittest.TestCase):
], ],
networks=None, networks=None,
volumes=None, volumes=None,
secrets=None,
), ),
) )
assert project.get_service('test')._get_volumes_from() == [container_name + ":rw"] assert project.get_service('test')._get_volumes_from() == [container_name + ":rw"]
@ -227,6 +231,7 @@ class ProjectTest(unittest.TestCase):
], ],
networks=None, networks=None,
volumes=None, volumes=None,
secrets=None,
), ),
) )
with mock.patch.object(Service, 'containers') as mock_return: with mock.patch.object(Service, 'containers') as mock_return:
@ -360,6 +365,7 @@ class ProjectTest(unittest.TestCase):
], ],
networks=None, networks=None,
volumes=None, volumes=None,
secrets=None,
), ),
) )
service = project.get_service('test') service = project.get_service('test')
@ -384,6 +390,7 @@ class ProjectTest(unittest.TestCase):
], ],
networks=None, networks=None,
volumes=None, volumes=None,
secrets=None,
), ),
) )
service = project.get_service('test') service = project.get_service('test')
@ -417,6 +424,7 @@ class ProjectTest(unittest.TestCase):
], ],
networks=None, networks=None,
volumes=None, volumes=None,
secrets=None,
), ),
) )
@ -437,6 +445,7 @@ class ProjectTest(unittest.TestCase):
], ],
networks=None, networks=None,
volumes=None, volumes=None,
secrets=None,
), ),
) )
@ -457,6 +466,7 @@ class ProjectTest(unittest.TestCase):
], ],
networks={'custom': {}}, networks={'custom': {}},
volumes=None, volumes=None,
secrets=None,
), ),
) )
@ -487,6 +497,7 @@ class ProjectTest(unittest.TestCase):
}], }],
networks=None, networks=None,
volumes=None, volumes=None,
secrets=None,
), ),
) )
self.assertEqual([c.id for c in project.containers()], ['1']) self.assertEqual([c.id for c in project.containers()], ['1'])
@ -503,6 +514,7 @@ class ProjectTest(unittest.TestCase):
}], }],
networks={'default': {}}, networks={'default': {}},
volumes={'data': {}}, volumes={'data': {}},
secrets=None,
), ),
) )
self.mock_client.remove_network.side_effect = NotFound(None, None, 'oops') self.mock_client.remove_network.side_effect = NotFound(None, None, 'oops')