Skip to content

Commit

Permalink
Validate deployment template/manifest (#406)
Browse files Browse the repository at this point in the history
* Validate deployment template/manifest in genconfig task
  • Loading branch information
blackchoey authored Oct 18, 2019
1 parent 68fba17 commit 8f5c920
Show file tree
Hide file tree
Showing 9 changed files with 566 additions and 7 deletions.
11 changes: 9 additions & 2 deletions iotedgedev/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,10 +247,17 @@ def deploy(manifest_file):
show_default=True,
required=False,
help="Specify the platform")
@click.option("--fail-on-validation-error",
"fail_on_validation_error",
is_flag=True,
default=False,
show_default=True,
required=False,
help="Fail the command when deployment manifest validation failed")
@with_telemetry
def genconfig(template_file, platform):
def genconfig(template_file, platform, fail_on_validation_error):
mod = Modules(envvars, output)
mod.build_push(template_file, platform, no_build=True, no_push=True)
mod.build_push(template_file, platform, no_build=True, no_push=True, fail_on_validation_error=fail_on_validation_error)


main.add_command(genconfig)
Expand Down
2 changes: 2 additions & 0 deletions iotedgedev/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ class Constants:
deployment_template_suffix = ".template.json"
deployment_template_schema_version = "1.0.0"
moduledir_placeholder_pattern = r'\${MODULEDIR<(.+)>(\..+)?}'
deployment_template_schema_url = "http://json.schemastore.org/azure-iot-edge-deployment-template-2.0"
deployment_manifest_schema_url = "http://json.schemastore.org/azure-iot-edge-deployment-2.0"
138 changes: 136 additions & 2 deletions iotedgedev/deploymentmanifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@
import shutil

import six
import jsonschema
from six.moves.urllib.request import urlopen

from .compat import PY2
from .utility import Utility
from .constants import Constants

if PY2:
from .compat import FileNotFoundError
Expand All @@ -21,14 +24,14 @@


class DeploymentManifest:
def __init__(self, envvars, output, utility, path, is_template):
def __init__(self, envvars, output, utility, path, is_template, expand_vars=True):
self.envvars = envvars
self.utility = utility
self.output = output
try:
self.path = path
self.is_template = is_template
self.json = json.loads(Utility.get_file_contents(path, expandvars=True))
self.json = json.loads(Utility.get_file_contents(path, expandvars=expand_vars))
except FileNotFoundError:
if is_template:
deployment_manifest_path = self.envvars.DEPLOYMENT_CONFIG_FILE_PATH
Expand Down Expand Up @@ -125,6 +128,9 @@ def expand_image_placeholders(self, replacements):
if module_name in replacements:
self.utility.nested_set(module_info, ["settings", "image"], replacements[module_name])

def expand_environment_variables(self):
self.json = json.loads(os.path.expandvars(json.dumps(self.json)))

def del_key(self, keys):
self.utility.del_key(self.json, keys)

Expand All @@ -136,6 +142,26 @@ def dump(self, path=None):
with open(path, "w") as deployment_manifest:
json.dump(self.json, deployment_manifest, indent=2)

def validate_deployment_template(self):
validation_success = True
try:
template_schema = json.loads(urlopen(Constants.deployment_template_schema_url).read().decode())
self._validate_json_schema(template_schema, self.json, "Deployment template")
except Exception as ex: # Ignore other non shcema validation errors
self.output.info("Unexpected error during deployment template schema validation, skip schema validation. Error:%s" % ex)

return validation_success

def validate_deployment_manifest(self):
validation_success = True
try:
validation_success = self._validate_deployment_manifest_schema()
validation_success &= self._validate_create_options()
except Exception as err:
self.output.info("Unexpected error during deployment manifest validation, skip the validation. Error:%s" % err)

return validation_success

@staticmethod
def get_image_placeholder(module_name, is_debug=False):
return "${{MODULES.{0}}}".format(module_name + ".debug" if is_debug else module_name)
Expand All @@ -147,3 +173,111 @@ def _get_module_content(self):
return self.json["moduleContent"]
else:
raise KeyError("modulesContent")

# Carefully check upper/lower case of the output when using this function
def _validate_json_schema(self, schema_object, json_object, schema_type):
validation_success = True
try:
self.output.info("Validating schema of %s." % schema_type.lower())
validator_class = jsonschema.validators.validator_for(schema_object)
validator = validator_class(schema_object)
validation_errors = validator.iter_errors(self.json)
error_detected = False
for error in validation_errors:
error_detected = True
self.output.warning("%s schema error: %s. Property path:%s" % (schema_type, error.message, "->".join(error.path)))
if error_detected:
self.output.warning("%s schema validation failed. Please see previous logs for more details" % schema_type)
else:
self.output.info("%s schema validation passed." % schema_type)
except jsonschema.exceptions.SchemaError as schemaErr:
self.output.info("Errors found in %s schema, skip schema validation. Error:%s" % (schema_type, schemaErr.message))
except Exception as ex: # Ignore other non schema validation errors
self.output.info("Unexpected error during %s schema validation, skip schema validation. Error:%s" % (schema_type, ex))

return validation_success

def _validate_deployment_manifest_schema(self):
validation_success = True
try:
deployment_schema = json.loads(urlopen(Constants.deployment_manifest_schema_url).read())
self._validate_json_schema(deployment_schema, self.json, "Deployment manifest")
except Exception as ex: # Ignore other non schema validation errors
self.output.info("Unexpected error during deployment manifest schema validation, skip schema validation. Error:%s" % ex)

return validation_success

# Call _validate_deployment_manifest_schema first. This function assumes createOptions are strings.
def _validate_create_options(self):
self.output.info("Start validating createOptions for all modules.")
modules = self.get_all_modules()
validation_success = True
for module_name, module_info in modules.items():
current_module_validation_success = True
try:
self.output.info("Validating createOptions for module %s" % module_name)
if "settings" in module_info and "createOptions" in module_info["settings"]:
current_module_validation_success = self._validate_create_options_for_module(module_name, module_info)
if current_module_validation_success:
self.output.info("createOptions of module %s validation passed" % module_name)
else:
self.output.info("No settings or createOptions property found in module %s. Skip createOptions validation." % module_name)
except Exception as ex:
self.output.info("Unexpected error occurs when validating createOptions for module %s: %s" % (module_name, ex))
finally:
validation_success &= current_module_validation_success
if (validation_success):
self.output.info("Validation for all createOptions passed.")
else:
self.output.warning("Errors found during createOptions validation. Please check the logs for details.")
return validation_success

def _validate_create_options_for_module(self, module_name, module_info):
validation_success = True
validation_success &= self._validate_create_options_lengh(module_name, module_info)
validation_success &= self._validate_create_options_format(module_name, module_info)
return validation_success

def _validate_create_options_lengh(self, module_name, module_info):
validation_success = True
create_options_value = module_info["settings"]["createOptions"]
if len(str(create_options_value)) > TWIN_VALUE_MAX_SIZE:
validation_success = False
self.output.warning("Length of createOptions in module %s exceeds %d" % (module_name, TWIN_VALUE_MAX_SIZE))
# Merge additional create options
for i in range(1, TWIN_VALUE_MAX_CHUNKS):
property_name = "createOptions0%d" % i
if property_name in module_info["settings"]:
create_options_value = module_info["settings"][property_name]
if len(str(create_options_value)) > TWIN_VALUE_MAX_SIZE:
validation_success = False
self.output.warning("Length of %s in module %s exceeds %d" % (property_name, module_name, TWIN_VALUE_MAX_SIZE))
else:
break
return validation_success

def _validate_create_options_format(self, module_name, module_info):
validation_success = True
create_options_string = self._merge_create_options(module_name, module_info)
if not create_options_string.startswith('{'):
validation_success = False
self.output.warning("createOptions of module %s should be an object" % module_name)
else:
try:
json.loads(create_options_string)
except ValueError as err:
validation_success = False
self.output.warning("createOptions of module %s is not a valid JSON string. Error: %s" % (module_name, err))
return validation_success

def _merge_create_options(self, module_name, module_info):
create_options = []
create_options.append(str(module_info["settings"]["createOptions"]))
# Merge additional create options
for i in range(1, TWIN_VALUE_MAX_CHUNKS):
property_name = "createOptions0%d" % i
if property_name in module_info["settings"]:
create_options.append(str(module_info["settings"][property_name]))
else:
break
return "".join(create_options).strip()
17 changes: 15 additions & 2 deletions iotedgedev/modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,12 @@ def build(self, template_file, platform):
def push(self, template_file, platform, no_build=False):
return self.build_push(template_file, platform, no_build=no_build)

def build_push(self, template_file, default_platform, no_build=False, no_push=False):
def build_push(self, template_file, default_platform, no_build=False, no_push=False, fail_on_validation_error=False):
self.output.header("BUILDING MODULES", suppress=no_build)

template_file_folder = os.path.dirname(template_file)
bypass_modules = self.utility.get_bypass_modules()
validation_success = True

# map image placeholder to tag.
# sample: ('${MODULES.filtermodule.amd64}', 'localhost:5000/filtermodule:0.0.1-amd64')
Expand All @@ -140,7 +141,10 @@ def build_push(self, template_file, default_platform, no_build=False, no_push=Fa
# sample: 'localhost:5000/filtermodule:0.0.1-amd64'
tags_to_build = set()

deployment_manifest = DeploymentManifest(self.envvars, self.output, self.utility, template_file, True)
self.output.info("Validating deployment template %s" % template_file)
deployment_manifest = DeploymentManifest(self.envvars, self.output, self.utility, template_file, True, False)
deployment_manifest.validate_deployment_template()
deployment_manifest.expand_environment_variables()

# get image tags for ${MODULES.modulename.xxx} placeholder
modules_path = os.path.join(template_file_folder, self.envvars.MODULES_PATH)
Expand Down Expand Up @@ -221,8 +225,11 @@ def build_push(self, template_file, default_platform, no_build=False, no_push=Fa
self.output.footer("BUILD COMPLETE", suppress=no_build)
self.output.footer("PUSH COMPLETE", suppress=no_push)

self.output.info("Expanding image placeholders")
deployment_manifest.expand_image_placeholders(replacements)
self.output.info("Converting createOptions")
deployment_manifest.convert_create_options()
self.output.info("Deleting template schema version")
template_schema_ver = deployment_manifest.get_template_schema_ver()
deployment_manifest.del_key(["$schema-template"])

Expand All @@ -233,6 +240,12 @@ def build_push(self, template_file, default_platform, no_build=False, no_push=Fa
self.output.info("Expanding '{0}' to '{1}'".format(os.path.basename(template_file), gen_deployment_manifest_path))
deployment_manifest.dump(gen_deployment_manifest_path)

self.output.info("Validating generated deployment manifest %s" % gen_deployment_manifest_path)
validation_success = deployment_manifest.validate_deployment_manifest()

if fail_on_validation_error and not validation_success:
raise Exception("Deployment manifest validation failed. Please see previous logs for more details.")

return gen_deployment_manifest_path

def _update_module_maps(self, placeholder_base, module, placeholder_tag_map, tag_build_profile_map, default_platform):
Expand Down
3 changes: 3 additions & 0 deletions iotedgedev/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ def info(self, text, suppress=False):
if not suppress:
self.echo(text, color='yellow')

def warning(self, text):
self.echo("Warning: %s" % text, color='yellow')

def status(self, text):
self.info(text)
self.line()
Expand Down
Loading

0 comments on commit 8f5c920

Please sign in to comment.