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 |