| OLD | NEW |
| 1 # Copyright 2014 The Chromium Authors. All rights reserved. | 1 # Copyright 2014 The Chromium Authors. All rights reserved. |
| 2 # Use of this source code is governed by a BSD-style license that can be | 2 # Use of this source code is governed by a BSD-style license that can be |
| 3 # found in the LICENSE file. | 3 # found in the LICENSE file. |
| 4 | 4 |
| 5 import copy |
| 5 import json | 6 import json |
| 6 import re | 7 import re |
| 7 | 8 |
| 9 from components.config import validation |
| 10 |
| 8 from proto import project_config_pb2 | 11 from proto import project_config_pb2 |
| 9 | 12 |
| 10 | 13 |
| 11 DIMENSION_KEY_RGX = re.compile(r'^[a-zA-Z\_\-]+$') | 14 DIMENSION_KEY_RGX = re.compile(r'^[a-zA-Z\_\-]+$') |
| 15 NO_PROPERTY = object() |
| 16 |
| 17 |
| 18 def read_properties(recipe): |
| 19 """Parses build properties from the recipe message. |
| 20 |
| 21 Expects the message to be valid. |
| 22 |
| 23 Uses NO_PROPERTY for empty values. |
| 24 """ |
| 25 result = dict(p.split(':', 1) for p in recipe.properties) |
| 26 for p in recipe.properties_j: |
| 27 k, v = p.split(':', 1) |
| 28 if not v: |
| 29 parsed = NO_PROPERTY |
| 30 else: |
| 31 parsed = json.loads(v) |
| 32 result[k] = parsed |
| 33 return result |
| 34 |
| 35 |
| 36 def merge_recipe(r1, r2): |
| 37 """Merges Recipe message r2 into r1. |
| 38 |
| 39 Expects messages to be valid. |
| 40 |
| 41 All properties are converted to properties_j. |
| 42 """ |
| 43 props = read_properties(r1) |
| 44 props.update(read_properties(r2)) |
| 45 |
| 46 r1.MergeFrom(r2) |
| 47 r1.properties[:] = [] |
| 48 r1.properties_j[:] = [ |
| 49 '%s:%s' % (k, json.dumps(v)) |
| 50 for k, v in sorted(props.iteritems()) |
| 51 if v is not NO_PROPERTY |
| 52 ] |
| 53 |
| 54 |
| 55 def merge_dimensions(d1, d2): |
| 56 """Merges dimensions. Values in d2 overwrite values in d1. |
| 57 |
| 58 Expects dimensions to be valid. |
| 59 |
| 60 If a dimensions value in d2 is empty, it is excluded from the result. |
| 61 """ |
| 62 parse = lambda d: dict(a.split(':', 1) for a in d) |
| 63 dims = parse(d1) |
| 64 dims.update(parse(d2)) |
| 65 return ['%s:%s' % (k, v) for k, v in sorted(dims.iteritems()) if v] |
| 66 |
| 67 |
| 68 def merge_builder(b1, b2): |
| 69 """Merges Builder message b2 into b1. Expects messages to be valid.""" |
| 70 dims = merge_dimensions(b1.dimensions, b2.dimensions) |
| 71 recipe = None |
| 72 if b1.HasField('recipe') or b2.HasField('recipe'): # pragma: no branch |
| 73 recipe = copy.deepcopy(b1.recipe) |
| 74 merge_recipe(recipe, b2.recipe) |
| 75 |
| 76 b1.MergeFrom(b2) |
| 77 b1.dimensions[:] = dims |
| 78 if recipe: # pragma: no branch |
| 79 b1.recipe.CopyFrom(recipe) |
| 80 |
| 81 |
| 82 # TODO(nodir): remove this function once all confgis are converted |
| 83 def normalize_swarming_cfg(cfg): |
| 84 """Converts deprecated fields into new ones. |
| 85 |
| 86 Does not check for presence of both new and deprecated fields, because |
| 87 configs will be migrated shortly after this change is deployed. |
| 88 """ |
| 89 if cfg.HasField('builder_defaults'): # pragma: no cover |
| 90 return |
| 91 defs = cfg.builder_defaults |
| 92 defs.swarming_tags[:] = cfg.common_swarming_tags |
| 93 defs.dimensions[:] = cfg.common_dimensions |
| 94 defs.recipe.CopyFrom(cfg.common_recipe) |
| 95 defs.execution_timeout_secs = cfg.common_execution_timeout_secs |
| 12 | 96 |
| 13 | 97 |
| 14 def validate_tag(tag, ctx): | 98 def validate_tag(tag, ctx): |
| 15 # a valid swarming tag is a string that contains ":" | 99 # a valid swarming tag is a string that contains ":" |
| 16 if ':' not in tag: | 100 if ':' not in tag: |
| 17 ctx.error('does not have ":": %s', tag) | 101 ctx.error('does not have ":": %s', tag) |
| 18 name = tag.split(':', 1)[0] | 102 name = tag.split(':', 1)[0] |
| 19 if name.lower() == 'builder': | 103 if name.lower() == 'builder': |
| 20 ctx.error( | 104 ctx.error( |
| 21 'do not specify builder tag; ' | 105 'do not specify builder tag; ' |
| (...skipping 15 matching lines...) Expand all Loading... |
| 37 if not DIMENSION_KEY_RGX.match(key): | 121 if not DIMENSION_KEY_RGX.match(key): |
| 38 ctx.error( | 122 ctx.error( |
| 39 'key "%s" does not match pattern "%s"', | 123 'key "%s" does not match pattern "%s"', |
| 40 key, DIMENSION_KEY_RGX.pattern) | 124 key, DIMENSION_KEY_RGX.pattern) |
| 41 if key in known_keys: | 125 if key in known_keys: |
| 42 ctx.error('duplicate key %s', key) | 126 ctx.error('duplicate key %s', key) |
| 43 else: | 127 else: |
| 44 known_keys.add(key) | 128 known_keys.add(key) |
| 45 | 129 |
| 46 | 130 |
| 47 def validate_recipe_cfg(recipe, common_recipe, ctx): | 131 def validate_recipe_cfg(recipe, ctx, final=True): |
| 48 common_recipe = common_recipe or project_config_pb2.Swarming.Recipe() | 132 """Validates a Recipe message. |
| 49 if not (recipe.name or common_recipe.name): | 133 |
| 134 If final is False, does not validate for completeness. |
| 135 """ |
| 136 if final and not recipe.name: |
| 50 ctx.error('name unspecified') | 137 ctx.error('name unspecified') |
| 51 if not (recipe.repository or common_recipe.repository): | 138 if final and not recipe.repository: |
| 52 ctx.error('repository unspecified') | 139 ctx.error('repository unspecified') |
| 53 validate_recipe_properties(recipe.properties, recipe.properties_j, ctx) | 140 validate_recipe_properties(recipe.properties, recipe.properties_j, ctx) |
| 54 | 141 |
| 55 | 142 |
| 56 def validate_recipe_properties(properties, properties_j, ctx): | 143 def validate_recipe_properties(properties, properties_j, ctx): |
| 57 keys = set() | 144 keys = set() |
| 58 | 145 |
| 59 def validate_key(key): | 146 def validate_key(key): |
| 60 if not key: | 147 if not key: |
| 61 ctx.error('key not specified') | 148 ctx.error('key not specified') |
| (...skipping 14 matching lines...) Expand all Loading... |
| 76 keys.add(key) | 163 keys.add(key) |
| 77 | 164 |
| 78 for i, p in enumerate(properties_j): | 165 for i, p in enumerate(properties_j): |
| 79 with ctx.prefix('properties_j #%d: ', i + 1): | 166 with ctx.prefix('properties_j #%d: ', i + 1): |
| 80 if ':' not in p: | 167 if ':' not in p: |
| 81 ctx.error('does not have colon') | 168 ctx.error('does not have colon') |
| 82 else: | 169 else: |
| 83 key, value = p.split(':', 1) | 170 key, value = p.split(':', 1) |
| 84 validate_key(key) | 171 validate_key(key) |
| 85 keys.add(key) | 172 keys.add(key) |
| 86 try: | 173 if value: |
| 87 json.loads(value) | 174 try: |
| 88 except ValueError as ex: | 175 json.loads(value) |
| 89 ctx.error(ex) | 176 except ValueError as ex: |
| 177 ctx.error(ex) |
| 90 | 178 |
| 91 | 179 |
| 92 def validate_builder_cfg( | 180 def validate_builder_cfg(builder, ctx, final=True): |
| 93 builder, ctx, bucket_has_pool_dim=False, common_recipe=None): | 181 """Validates a Builder message. |
| 94 if not builder.name: | 182 |
| 183 If final is False, does not validate for completeness. |
| 184 """ |
| 185 if final and not builder.name: |
| 95 ctx.error('name unspecified') | 186 ctx.error('name unspecified') |
| 96 | 187 |
| 97 for i, t in enumerate(builder.swarming_tags): | 188 for i, t in enumerate(builder.swarming_tags): |
| 98 with ctx.prefix('tag #%d: ', i + 1): | 189 with ctx.prefix('tag #%d: ', i + 1): |
| 99 validate_tag(t, ctx) | 190 validate_tag(t, ctx) |
| 100 | 191 |
| 101 validate_dimensions('dimension', builder.dimensions, ctx) | 192 validate_dimensions('dimension', builder.dimensions, ctx) |
| 102 if not bucket_has_pool_dim and not has_pool_dimension(builder.dimensions): | 193 if final and not has_pool_dimension(builder.dimensions): |
| 103 ctx.error( | 194 ctx.error('has no "pool" dimension') |
| 104 'has no "pool" dimension. ' | |
| 105 'Either define it in the builder or in "common_dimensions"') | |
| 106 | 195 |
| 107 if not builder.HasField('recipe') and not common_recipe: | 196 with ctx.prefix('recipe: '): |
| 108 ctx.error('recipe unspecified') | 197 validate_recipe_cfg(builder.recipe, ctx, final=final) |
| 109 else: | |
| 110 with ctx.prefix('recipe: '): | |
| 111 validate_recipe_cfg(builder.recipe, common_recipe, ctx) | |
| 112 | 198 |
| 113 if builder.priority > 200: | 199 if builder.priority > 200: |
| 114 ctx.error('priority must be in [0, 200] range; got %d', builder.priority) | 200 ctx.error('priority must be in [0, 200] range; got %d', builder.priority) |
| 115 | 201 |
| 116 | 202 |
| 117 def validate_cfg(swarming, ctx): | 203 def validate_cfg(swarming, ctx): |
| 204 swarming = copy.deepcopy(swarming) |
| 205 normalize_swarming_cfg(swarming) |
| 206 |
| 207 def make_subctx(): |
| 208 return validation.Context( |
| 209 on_message=lambda msg: ctx.msg(msg.severity, '%s', msg.text)) |
| 210 |
| 118 if not swarming.hostname: | 211 if not swarming.hostname: |
| 119 ctx.error('hostname unspecified') | 212 ctx.error('hostname unspecified') |
| 120 for i, t in enumerate(swarming.common_swarming_tags): | |
| 121 with ctx.prefix('common tag #%d: ', i + 1): | |
| 122 validate_tag(t, ctx) | |
| 123 | |
| 124 validate_dimensions('common dimension', swarming.common_dimensions, ctx) | |
| 125 has_pool_dim = has_pool_dimension(swarming.common_dimensions) | |
| 126 | |
| 127 if swarming.task_template_canary_percentage > 100: | 213 if swarming.task_template_canary_percentage > 100: |
| 128 ctx.error('task_template_canary_percentage must must be in [0, 100]') | 214 ctx.error('task_template_canary_percentage must must be in [0, 100]') |
| 129 | 215 |
| 130 common_recipe = None | 216 with ctx.prefix('builder_defaults: '): |
| 131 if swarming.HasField('common_recipe'): | 217 if swarming.builder_defaults.name: |
| 132 common_recipe = swarming.common_recipe | 218 ctx.error('do not specify default name') |
| 133 with ctx.prefix('common_recipe: '): | 219 subctx = make_subctx() |
| 134 validate_recipe_properties( | 220 validate_builder_cfg(swarming.builder_defaults, subctx, final=False) |
| 135 swarming.common_recipe.properties, | 221 builder_defaults_has_errors = subctx.result().has_errors |
| 136 swarming.common_recipe.properties_j, ctx) | |
| 137 | 222 |
| 138 for i, b in enumerate(swarming.builders): | 223 for i, b in enumerate(swarming.builders): |
| 139 with ctx.prefix('builder %s: ' % (b.name or '#%s' % (i + 1))): | 224 with ctx.prefix('builder %s: ' % (b.name or '#%s' % (i + 1))): |
| 140 validate_builder_cfg( | 225 # Validate b before merging, otherwise merging will fail. |
| 141 b, ctx, bucket_has_pool_dim=has_pool_dim, | 226 subctx = make_subctx() |
| 142 common_recipe=common_recipe) | 227 validate_builder_cfg(b, subctx, final=False) |
| 228 if subctx.result().has_errors or builder_defaults_has_errors: |
| 229 # Do no try to merge invalid configs. |
| 230 continue |
| 231 |
| 232 merged = copy.deepcopy(swarming.builder_defaults) |
| 233 merge_builder(merged, b) |
| 234 validate_builder_cfg(merged, ctx) |
| 143 | 235 |
| 144 | 236 |
| 145 def has_pool_dimension(dimensions): | 237 def has_pool_dimension(dimensions): |
| 146 return any(d.startswith('pool:') for d in dimensions) | 238 return any(d.startswith('pool:') for d in dimensions) |
| OLD | NEW |