Split validation into fields and service

We want to give feedback to the user as soon as possible about the
validity of the config supplied for the services.

When extending a service, we can validate that the fields are
correct against our schema but we must wait until the *end* of
the extends cycle once all of the extended dicts have been merged
into the service dict, to perform the final validation check on the
config to ensure it is a complete valid service.

Doing this before that had happened resulted in false reports of
invalid config, as common config when split out, by itself, is not
a valid service but *is* valid config to be included.

Signed-off-by: Mazz Mosley <mazz@houseofmnowster.com>
This commit is contained in:
Mazz Mosley 2015-08-28 18:49:17 +01:00
parent 4a8b2947ca
commit 950577d60f
10 changed files with 88 additions and 26 deletions

View File

@ -10,7 +10,8 @@ from .errors import CircularReference
from .errors import ComposeFileNotFound from .errors import ComposeFileNotFound
from .errors import ConfigurationError from .errors import ConfigurationError
from .interpolation import interpolate_environment_variables from .interpolation import interpolate_environment_variables
from .validation import validate_against_schema from .validation import validate_against_fields_schema
from .validation import validate_against_service_schema
from .validation import validate_extended_service_exists from .validation import validate_extended_service_exists
from .validation import validate_extends_file_path from .validation import validate_extends_file_path
from .validation import validate_service_names from .validation import validate_service_names
@ -139,7 +140,7 @@ def load(config_details):
config, working_dir, filename = config_details config, working_dir, filename = config_details
processed_config = pre_process_config(config) processed_config = pre_process_config(config)
validate_against_schema(processed_config) validate_against_fields_schema(processed_config)
service_dicts = [] service_dicts = []
@ -193,7 +194,7 @@ class ServiceLoader(object):
full_extended_config, full_extended_config,
self.extended_config_path self.extended_config_path
) )
validate_against_schema(full_extended_config) validate_against_fields_schema(full_extended_config)
self.extended_config = full_extended_config[self.extended_service_name] self.extended_config = full_extended_config[self.extended_service_name]
else: else:
@ -205,6 +206,10 @@ class ServiceLoader(object):
def make_service_dict(self): def make_service_dict(self):
self.service_dict = self.resolve_extends() self.service_dict = self.resolve_extends()
if not self.already_seen:
validate_against_service_schema(self.service_dict)
return process_container_options(self.service_dict, working_dir=self.working_dir) return process_container_options(self.service_dict, working_dir=self.working_dir)
def resolve_environment(self): def resolve_environment(self):

View File

@ -106,24 +106,6 @@
"working_dir": {"type": "string"} "working_dir": {"type": "string"}
}, },
"anyOf": [
{
"required": ["build"],
"not": {"required": ["image"]}
},
{
"required": ["image"],
"not": {"anyOf": [
{"required": ["build"]},
{"required": ["dockerfile"]}
]}
},
{
"required": ["extends"],
"not": {"required": ["build", "image"]}
}
],
"dependencies": { "dependencies": {
"memswap_limit": ["mem_limit"] "memswap_limit": ["mem_limit"]
}, },

View File

@ -0,0 +1,39 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"properties": {
"name": {"type": "string"}
},
"required": ["name"],
"allOf": [
{"$ref": "fields_schema.json#/definitions/service"},
{"$ref": "#/definitions/service_constraints"}
],
"definitions": {
"service_constraints": {
"anyOf": [
{
"required": ["build"],
"not": {"required": ["image"]}
},
{
"required": ["image"],
"not": {"anyOf": [
{"required": ["build"]},
{"required": ["dockerfile"]}
]}
},
{
"required": ["extends"],
"not": {"required": ["build", "image"]}
}
]
}
}
}

View File

@ -5,6 +5,7 @@ from functools import wraps
from docker.utils.ports import split_port from docker.utils.ports import split_port
from jsonschema import Draft4Validator from jsonschema import Draft4Validator
from jsonschema import FormatChecker from jsonschema import FormatChecker
from jsonschema import RefResolver
from jsonschema import ValidationError from jsonschema import ValidationError
from .errors import ConfigurationError from .errors import ConfigurationError
@ -210,14 +211,25 @@ def process_errors(errors):
return "\n".join(root_msgs + invalid_keys + required + type_errors + other_errors) return "\n".join(root_msgs + invalid_keys + required + type_errors + other_errors)
def validate_against_schema(config): def validate_against_fields_schema(config):
schema_filename = "fields_schema.json"
return _validate_against_schema(config, schema_filename)
def validate_against_service_schema(config):
schema_filename = "service_schema.json"
return _validate_against_schema(config, schema_filename)
def _validate_against_schema(config, schema_filename):
config_source_dir = os.path.dirname(os.path.abspath(__file__)) config_source_dir = os.path.dirname(os.path.abspath(__file__))
schema_file = os.path.join(config_source_dir, "schema.json") schema_file = os.path.join(config_source_dir, schema_filename)
with open(schema_file, "r") as schema_fh: with open(schema_file, "r") as schema_fh:
schema = json.load(schema_fh) schema = json.load(schema_fh)
validation_output = Draft4Validator(schema, format_checker=FormatChecker(["ports"])) resolver = RefResolver('file://' + config_source_dir + '/', schema)
validation_output = Draft4Validator(schema, resolver=resolver, format_checker=FormatChecker(["ports"]))
errors = [error for error in sorted(validation_output.iter_errors(config), key=str)] errors = [error for error in sorted(validation_output.iter_errors(config), key=str)]
if errors: if errors:

View File

@ -1,5 +1,4 @@
myweb: myweb:
extends: extends:
file: valid-composite-extends.yml
service: web service: web
web:
command: top

View File

@ -0,0 +1,5 @@
myweb:
build: '.'
extends:
file: 'valid-composite-extends.yml'
service: web

View File

@ -0,0 +1,6 @@
myweb:
build: '.'
extends:
file: valid-common.yml
service: common-config
command: top

View File

@ -0,0 +1,3 @@
common-config:
environment:
- FOO=1

View File

@ -0,0 +1,2 @@
web:
command: top

View File

@ -866,6 +866,7 @@ class ExtendsTest(unittest.TestCase):
self.assertEquals(len(service), 1) self.assertEquals(len(service), 1)
self.assertIsInstance(service[0], dict) self.assertIsInstance(service[0], dict)
self.assertEquals(service[0]['command'], "/bin/true")
def test_extended_service_with_invalid_config(self): def test_extended_service_with_invalid_config(self):
expected_error_msg = "Service 'myweb' has neither an image nor a build path specified" expected_error_msg = "Service 'myweb' has neither an image nor a build path specified"
@ -873,6 +874,10 @@ class ExtendsTest(unittest.TestCase):
with self.assertRaisesRegexp(ConfigurationError, expected_error_msg): with self.assertRaisesRegexp(ConfigurationError, expected_error_msg):
load_from_filename('tests/fixtures/extends/service-with-invalid-schema.yml') load_from_filename('tests/fixtures/extends/service-with-invalid-schema.yml')
def test_extended_service_with_valid_config(self):
service = load_from_filename('tests/fixtures/extends/service-with-valid-composite-extends.yml')
self.assertEquals(service[0]['command'], "top")
def test_extends_file_defaults_to_self(self): def test_extends_file_defaults_to_self(self):
""" """
Test not specifying a file in our extends options that the Test not specifying a file in our extends options that the
@ -955,6 +960,10 @@ class ExtendsTest(unittest.TestCase):
with self.assertRaisesRegexp(ConfigurationError, err_msg): with self.assertRaisesRegexp(ConfigurationError, err_msg):
load_from_filename('tests/fixtures/extends/nonexistent-service.yml') load_from_filename('tests/fixtures/extends/nonexistent-service.yml')
def test_partial_service_config_in_extends_is_still_valid(self):
dicts = load_from_filename('tests/fixtures/extends/valid-common-config.yml')
self.assertEqual(dicts[0]['environment'], {'FOO': '1'})
class BuildPathTest(unittest.TestCase): class BuildPathTest(unittest.TestCase):
def setUp(self): def setUp(self):