Chromium Code Reviews| 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 json | 5 import json |
| 6 import re | 6 import re |
| 7 | 7 |
| 8 from proto import project_config_pb2 | 8 from proto import project_config_pb2 |
| 9 | 9 |
| 10 | 10 |
| 11 DIMENSION_KEY_RGX = re.compile(r'^[a-zA-Z\_\-]+$') | 11 DIMENSION_KEY_RGX = re.compile(r'^[a-zA-Z\_\-]+$') |
| 12 NO_PROPERTY = object() | |
| 13 | |
| 14 | |
| 15 def copy_msg(msg): | |
| 16 """Clones a protobuf message.""" | |
|
Vadim Sh.
2016/08/03 20:58:11
how does it handle fields that are protos themselv
nodir
2016/08/03 21:38:11
removed in favor of copy.deepcopy because this thi
| |
| 17 copy = type(msg)() | |
| 18 copy.CopyFrom(msg) | |
| 19 return copy | |
| 20 | |
| 21 | |
| 22 def read_properties(recipe): | |
| 23 """Parses build properties from the recipe message. | |
| 24 | |
| 25 Expects the message to be valid. | |
| 26 | |
| 27 Uses NO_PROPERTY for empty values. | |
| 28 """ | |
| 29 result = dict(p.split(':', 1) for p in recipe.properties) | |
| 30 for p in recipe.properties_j: | |
| 31 k, v = p.split(':', 1) | |
| 32 if not v: | |
|
Vadim Sh.
2016/08/03 20:58:12
I find it a bit sad that 'properties: "a:"' and 'p
nodir
2016/08/03 21:38:11
unfortunately no, because
properties_j: "a:\"b\""
| |
| 33 parsed = NO_PROPERTY | |
| 34 else: | |
| 35 parsed = json.loads(v) | |
| 36 result[k] = parsed | |
| 37 return result | |
| 38 | |
| 39 | |
| 40 def merge_recipe(r1, r2): | |
| 41 """Merges Recipe message r2 into r1. | |
| 42 | |
| 43 Expects messages to be valid. | |
| 44 | |
| 45 All properties are converted to properties_j. | |
| 46 """ | |
| 47 props = read_properties(r1) | |
| 48 props.update(read_properties(r2)) | |
| 49 | |
| 50 r1.MergeFrom(r2) | |
| 51 r1.properties[:] = [] | |
| 52 r1.properties_j[:] = [ | |
| 53 '%s:%s' % (k, json.dumps(v)) | |
| 54 for k, v in sorted(props.iteritems()) | |
| 55 if v is not NO_PROPERTY | |
| 56 ] | |
| 57 | |
| 58 | |
| 59 def merge_dimensions(d1, d2): | |
| 60 """Merges dimensions. Values in d2 overwrite values in d1. | |
| 61 | |
| 62 Expects dimensions to be valid. | |
| 63 | |
| 64 If a dimensions value in d2 is empty, it is excluded from the result. | |
| 65 """ | |
| 66 parse = lambda d: dict(a.split(':', 1) for a in d) | |
| 67 dims = parse(d1) | |
| 68 dims.update(parse(d2)) | |
| 69 return ['%s:%s' % (k, v) for k, v in sorted(dims.iteritems()) if v] | |
| 70 | |
| 71 | |
| 72 def merge_builder(b1, b2): | |
| 73 """Merges Builder message b2 into b1. Expects messages to be valid.""" | |
| 74 dims = merge_dimensions(b1.dimensions, b2.dimensions) | |
| 75 recipe = None | |
| 76 if b1.HasField('recipe') or b2.HasField('recipe'): # pragma: no branch | |
| 77 recipe = copy_msg(b1.recipe) | |
| 78 merge_recipe(recipe, b2.recipe) | |
| 79 | |
| 80 b1.MergeFrom(b2) | |
| 81 b1.dimensions[:] = dims | |
| 82 if recipe: # pragma: no branch | |
| 83 b1.recipe.CopyFrom(recipe) | |
| 84 | |
| 85 | |
| 86 # TODO(nodir): remove this function once all confgis are converted | |
| 87 def normalize_swarming_cfg(cfg): | |
| 88 """Converts deprecated fields into new ones. | |
| 89 | |
| 90 Does not check for presence of both new and deprecated fields, because | |
| 91 configs will be migrated shortly after this change is deployed. | |
| 92 """ | |
| 93 if cfg.HasField('builder_defaults'): # pragma: no cover | |
| 94 return | |
| 95 defs = cfg.builder_defaults | |
| 96 defs.swarming_tags[:] = cfg.common_swarming_tags | |
| 97 defs.dimensions[:] = cfg.common_dimensions | |
| 98 defs.recipe.CopyFrom(cfg.common_recipe) | |
| 99 defs.execution_timeout_secs = cfg.common_execution_timeout_secs | |
| 12 | 100 |
| 13 | 101 |
| 14 def validate_tag(tag, ctx): | 102 def validate_tag(tag, ctx): |
| 15 # a valid swarming tag is a string that contains ":" | 103 # a valid swarming tag is a string that contains ":" |
| 16 if ':' not in tag: | 104 if ':' not in tag: |
| 17 ctx.error('does not have ":": %s', tag) | 105 ctx.error('does not have ":": %s', tag) |
| 18 name = tag.split(':', 1)[0] | 106 name = tag.split(':', 1)[0] |
| 19 if name.lower() == 'builder': | 107 if name.lower() == 'builder': |
| 20 ctx.error( | 108 ctx.error( |
| 21 'do not specify builder tag; ' | 109 'do not specify builder tag; ' |
| (...skipping 15 matching lines...) Expand all Loading... | |
| 37 if not DIMENSION_KEY_RGX.match(key): | 125 if not DIMENSION_KEY_RGX.match(key): |
| 38 ctx.error( | 126 ctx.error( |
| 39 'key "%s" does not match pattern "%s"', | 127 'key "%s" does not match pattern "%s"', |
| 40 key, DIMENSION_KEY_RGX.pattern) | 128 key, DIMENSION_KEY_RGX.pattern) |
| 41 if key in known_keys: | 129 if key in known_keys: |
| 42 ctx.error('duplicate key %s', key) | 130 ctx.error('duplicate key %s', key) |
| 43 else: | 131 else: |
| 44 known_keys.add(key) | 132 known_keys.add(key) |
| 45 | 133 |
| 46 | 134 |
| 47 def validate_recipe_cfg(recipe, common_recipe, ctx): | 135 def validate_recipe_cfg(recipe, ctx, final=True): |
| 48 common_recipe = common_recipe or project_config_pb2.Swarming.Recipe() | 136 """Validates a Recipe message. |
| 49 if not (recipe.name or common_recipe.name): | 137 |
| 138 If final is False, does not validate for completeness. | |
| 139 """ | |
| 140 if final and not recipe.name: | |
| 50 ctx.error('name unspecified') | 141 ctx.error('name unspecified') |
| 51 if not (recipe.repository or common_recipe.repository): | 142 if final and not recipe.repository: |
| 52 ctx.error('repository unspecified') | 143 ctx.error('repository unspecified') |
| 53 validate_recipe_properties(recipe.properties, recipe.properties_j, ctx) | 144 validate_recipe_properties(recipe.properties, recipe.properties_j, ctx) |
| 54 | 145 |
| 55 | 146 |
| 56 def validate_recipe_properties(properties, properties_j, ctx): | 147 def validate_recipe_properties(properties, properties_j, ctx): |
| 57 keys = set() | 148 keys = set() |
| 58 | 149 |
| 59 def validate_key(key): | 150 def validate_key(key): |
| 60 if not key: | 151 if not key: |
| 61 ctx.error('key not specified') | 152 ctx.error('key not specified') |
| (...skipping 14 matching lines...) Expand all Loading... | |
| 76 keys.add(key) | 167 keys.add(key) |
| 77 | 168 |
| 78 for i, p in enumerate(properties_j): | 169 for i, p in enumerate(properties_j): |
| 79 with ctx.prefix('properties_j #%d: ', i + 1): | 170 with ctx.prefix('properties_j #%d: ', i + 1): |
| 80 if ':' not in p: | 171 if ':' not in p: |
| 81 ctx.error('does not have colon') | 172 ctx.error('does not have colon') |
| 82 else: | 173 else: |
| 83 key, value = p.split(':', 1) | 174 key, value = p.split(':', 1) |
| 84 validate_key(key) | 175 validate_key(key) |
| 85 keys.add(key) | 176 keys.add(key) |
| 86 try: | 177 if value: |
| 87 json.loads(value) | 178 try: |
| 88 except ValueError as ex: | 179 json.loads(value) |
| 89 ctx.error(ex) | 180 except ValueError as ex: |
| 181 ctx.error(ex) | |
| 90 | 182 |
| 91 | 183 |
| 92 def validate_builder_cfg( | 184 def validate_builder_cfg(builder, ctx, final=True): |
| 93 builder, ctx, bucket_has_pool_dim=False, common_recipe=None): | 185 """Validates a Builder message. |
| 94 if not builder.name: | 186 |
| 187 If final is False, does not validate for completeness. | |
| 188 """ | |
| 189 if final and not builder.name: | |
| 95 ctx.error('name unspecified') | 190 ctx.error('name unspecified') |
| 96 | 191 |
| 97 for i, t in enumerate(builder.swarming_tags): | 192 for i, t in enumerate(builder.swarming_tags): |
| 98 with ctx.prefix('tag #%d: ', i + 1): | 193 with ctx.prefix('tag #%d: ', i + 1): |
| 99 validate_tag(t, ctx) | 194 validate_tag(t, ctx) |
| 100 | 195 |
| 101 validate_dimensions('dimension', builder.dimensions, ctx) | 196 validate_dimensions('dimension', builder.dimensions, ctx) |
| 102 if not bucket_has_pool_dim and not has_pool_dimension(builder.dimensions): | 197 if final and not has_pool_dimension(builder.dimensions): |
| 103 ctx.error( | 198 ctx.error('has no "pool" dimension') |
| 104 'has no "pool" dimension. ' | |
| 105 'Either define it in the builder or in "common_dimensions"') | |
| 106 | 199 |
| 107 if not builder.HasField('recipe') and not common_recipe: | 200 with ctx.prefix('recipe: '): |
| 108 ctx.error('recipe unspecified') | 201 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 | 202 |
| 113 if builder.priority > 200: | 203 if builder.priority > 200: |
| 114 ctx.error('priority must be in [0, 200] range; got %d', builder.priority) | 204 ctx.error('priority must be in [0, 200] range; got %d', builder.priority) |
| 115 | 205 |
| 116 | 206 |
| 117 def validate_cfg(swarming, ctx): | 207 def validate_cfg(swarming, ctx): |
| 208 swarming = copy_msg(swarming) | |
| 209 normalize_swarming_cfg(swarming) | |
| 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 = ctx.proxy(ctx) |
|
Vadim Sh.
2016/08/03 20:58:11
huh? what is 'proxy'?
nodir
2016/08/03 21:38:11
ugh, I forgot that validation context is in a diff
| |
| 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 = ctx.proxy(ctx) |
| 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_msg(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 |