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 |