Index: appengine/cr-buildbucket/swarming/swarmingcfg.py |
diff --git a/appengine/cr-buildbucket/swarming/swarmingcfg.py b/appengine/cr-buildbucket/swarming/swarmingcfg.py |
index 324151cea03a0e6dbb9b5551453daf6ff99283cd..7e7cd68b8538a6a499fab22652c6ccf97f56e2c3 100644 |
--- a/appengine/cr-buildbucket/swarming/swarmingcfg.py |
+++ b/appengine/cr-buildbucket/swarming/swarmingcfg.py |
@@ -2,13 +2,97 @@ |
# Use of this source code is governed by a BSD-style license that can be |
# found in the LICENSE file. |
+import copy |
import json |
import re |
+from components.config import validation |
+ |
from proto import project_config_pb2 |
DIMENSION_KEY_RGX = re.compile(r'^[a-zA-Z\_\-]+$') |
+NO_PROPERTY = object() |
+ |
+ |
+def read_properties(recipe): |
+ """Parses build properties from the recipe message. |
+ |
+ Expects the message to be valid. |
+ |
+ Uses NO_PROPERTY for empty values. |
+ """ |
+ result = dict(p.split(':', 1) for p in recipe.properties) |
+ for p in recipe.properties_j: |
+ k, v = p.split(':', 1) |
+ if not v: |
+ parsed = NO_PROPERTY |
+ else: |
+ parsed = json.loads(v) |
+ result[k] = parsed |
+ return result |
+ |
+ |
+def merge_recipe(r1, r2): |
+ """Merges Recipe message r2 into r1. |
+ |
+ Expects messages to be valid. |
+ |
+ All properties are converted to properties_j. |
+ """ |
+ props = read_properties(r1) |
+ props.update(read_properties(r2)) |
+ |
+ r1.MergeFrom(r2) |
+ r1.properties[:] = [] |
+ r1.properties_j[:] = [ |
+ '%s:%s' % (k, json.dumps(v)) |
+ for k, v in sorted(props.iteritems()) |
+ if v is not NO_PROPERTY |
+ ] |
+ |
+ |
+def merge_dimensions(d1, d2): |
+ """Merges dimensions. Values in d2 overwrite values in d1. |
+ |
+ Expects dimensions to be valid. |
+ |
+ If a dimensions value in d2 is empty, it is excluded from the result. |
+ """ |
+ parse = lambda d: dict(a.split(':', 1) for a in d) |
+ dims = parse(d1) |
+ dims.update(parse(d2)) |
+ return ['%s:%s' % (k, v) for k, v in sorted(dims.iteritems()) if v] |
+ |
+ |
+def merge_builder(b1, b2): |
+ """Merges Builder message b2 into b1. Expects messages to be valid.""" |
+ dims = merge_dimensions(b1.dimensions, b2.dimensions) |
+ recipe = None |
+ if b1.HasField('recipe') or b2.HasField('recipe'): # pragma: no branch |
+ recipe = copy.deepcopy(b1.recipe) |
+ merge_recipe(recipe, b2.recipe) |
+ |
+ b1.MergeFrom(b2) |
+ b1.dimensions[:] = dims |
+ if recipe: # pragma: no branch |
+ b1.recipe.CopyFrom(recipe) |
+ |
+ |
+# TODO(nodir): remove this function once all confgis are converted |
+def normalize_swarming_cfg(cfg): |
+ """Converts deprecated fields into new ones. |
+ |
+ Does not check for presence of both new and deprecated fields, because |
+ configs will be migrated shortly after this change is deployed. |
+ """ |
+ if cfg.HasField('builder_defaults'): # pragma: no cover |
+ return |
+ defs = cfg.builder_defaults |
+ defs.swarming_tags[:] = cfg.common_swarming_tags |
+ defs.dimensions[:] = cfg.common_dimensions |
+ defs.recipe.CopyFrom(cfg.common_recipe) |
+ defs.execution_timeout_secs = cfg.common_execution_timeout_secs |
def validate_tag(tag, ctx): |
@@ -44,11 +128,14 @@ def validate_dimensions(field_name, dimensions, ctx): |
known_keys.add(key) |
-def validate_recipe_cfg(recipe, common_recipe, ctx): |
- common_recipe = common_recipe or project_config_pb2.Swarming.Recipe() |
- if not (recipe.name or common_recipe.name): |
+def validate_recipe_cfg(recipe, ctx, final=True): |
+ """Validates a Recipe message. |
+ |
+ If final is False, does not validate for completeness. |
+ """ |
+ if final and not recipe.name: |
ctx.error('name unspecified') |
- if not (recipe.repository or common_recipe.repository): |
+ if final and not recipe.repository: |
ctx.error('repository unspecified') |
validate_recipe_properties(recipe.properties, recipe.properties_j, ctx) |
@@ -83,15 +170,19 @@ def validate_recipe_properties(properties, properties_j, ctx): |
key, value = p.split(':', 1) |
validate_key(key) |
keys.add(key) |
- try: |
- json.loads(value) |
- except ValueError as ex: |
- ctx.error(ex) |
+ if value: |
+ try: |
+ json.loads(value) |
+ except ValueError as ex: |
+ ctx.error(ex) |
-def validate_builder_cfg( |
- builder, ctx, bucket_has_pool_dim=False, common_recipe=None): |
- if not builder.name: |
+def validate_builder_cfg(builder, ctx, final=True): |
+ """Validates a Builder message. |
+ |
+ If final is False, does not validate for completeness. |
+ """ |
+ if final and not builder.name: |
ctx.error('name unspecified') |
for i, t in enumerate(builder.swarming_tags): |
@@ -99,47 +190,48 @@ def validate_builder_cfg( |
validate_tag(t, ctx) |
validate_dimensions('dimension', builder.dimensions, ctx) |
- if not bucket_has_pool_dim and not has_pool_dimension(builder.dimensions): |
- ctx.error( |
- 'has no "pool" dimension. ' |
- 'Either define it in the builder or in "common_dimensions"') |
+ if final and not has_pool_dimension(builder.dimensions): |
+ ctx.error('has no "pool" dimension') |
- if not builder.HasField('recipe') and not common_recipe: |
- ctx.error('recipe unspecified') |
- else: |
- with ctx.prefix('recipe: '): |
- validate_recipe_cfg(builder.recipe, common_recipe, ctx) |
+ with ctx.prefix('recipe: '): |
+ validate_recipe_cfg(builder.recipe, ctx, final=final) |
if builder.priority > 200: |
ctx.error('priority must be in [0, 200] range; got %d', builder.priority) |
def validate_cfg(swarming, ctx): |
- if not swarming.hostname: |
- ctx.error('hostname unspecified') |
- for i, t in enumerate(swarming.common_swarming_tags): |
- with ctx.prefix('common tag #%d: ', i + 1): |
- validate_tag(t, ctx) |
+ swarming = copy.deepcopy(swarming) |
+ normalize_swarming_cfg(swarming) |
- validate_dimensions('common dimension', swarming.common_dimensions, ctx) |
- has_pool_dim = has_pool_dimension(swarming.common_dimensions) |
+ def make_subctx(): |
+ return validation.Context( |
+ on_message=lambda msg: ctx.msg(msg.severity, '%s', msg.text)) |
+ if not swarming.hostname: |
+ ctx.error('hostname unspecified') |
if swarming.task_template_canary_percentage > 100: |
ctx.error('task_template_canary_percentage must must be in [0, 100]') |
- common_recipe = None |
- if swarming.HasField('common_recipe'): |
- common_recipe = swarming.common_recipe |
- with ctx.prefix('common_recipe: '): |
- validate_recipe_properties( |
- swarming.common_recipe.properties, |
- swarming.common_recipe.properties_j, ctx) |
+ with ctx.prefix('builder_defaults: '): |
+ if swarming.builder_defaults.name: |
+ ctx.error('do not specify default name') |
+ subctx = make_subctx() |
+ validate_builder_cfg(swarming.builder_defaults, subctx, final=False) |
+ builder_defaults_has_errors = subctx.result().has_errors |
for i, b in enumerate(swarming.builders): |
with ctx.prefix('builder %s: ' % (b.name or '#%s' % (i + 1))): |
- validate_builder_cfg( |
- b, ctx, bucket_has_pool_dim=has_pool_dim, |
- common_recipe=common_recipe) |
+ # Validate b before merging, otherwise merging will fail. |
+ subctx = make_subctx() |
+ validate_builder_cfg(b, subctx, final=False) |
+ if subctx.result().has_errors or builder_defaults_has_errors: |
+ # Do no try to merge invalid configs. |
+ continue |
+ |
+ merged = copy.deepcopy(swarming.builder_defaults) |
+ merge_builder(merged, b) |
+ validate_builder_cfg(merged, ctx) |
def has_pool_dimension(dimensions): |