Skip to content

Commit f65487b

Browse files
authored
Merge pull request #5321 from cognifloyd/pack-config-more-jsonschema
Pack config: Process defaults and datastore values in patternProperties and additionalItems
2 parents d65555e + 36ecc4d commit f65487b

File tree

9 files changed

+551
-29
lines changed

9 files changed

+551
-29
lines changed

CHANGELOG.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ Fixed
99

1010
* Fix redis SSL problems with sentinel #5660
1111

12+
* Fix a bug in the pack config loader so that objects covered by an ``patternProperties`` schema
13+
or arrays using ``additionalItems`` schema(s) can use encrypted datastore keys and have their
14+
default values applied correctly. #5321
15+
16+
Contributed by @cognifloyd.
17+
1218
Added
1319
~~~~~
1420

st2common/st2common/util/config_loader.py

Lines changed: 174 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from __future__ import absolute_import
1717
import copy
18+
import re
1819

1920
import six
2021

@@ -99,21 +100,118 @@ def _get_values_for_config(self, config_schema_db, config_db):
99100
return config
100101

101102
@staticmethod
102-
def _get_object_property_schema(object_schema, additional_properties_keys=None):
103+
def _get_object_properties_schema(object_schema, object_keys=None):
103104
"""
104-
Create a schema for an object property using both additionalProperties and properties.
105+
Create a schema for an object property using all of: properties,
106+
patternProperties, and additionalProperties.
107+
108+
This 'flattens' properties, patternProperties, and additionalProperties
109+
so that we can handle patternProperties and additionalProperties
110+
as if they were defined in properties.
111+
So, every key in object_keys will be assigned a schema
112+
from properties, patternProperties, or additionalProperties.
113+
114+
NOTE: order of precedence: properties, patternProperties, additionalProperties
115+
So, the additionalProperties schema is only used for keys that are not in
116+
properties and that do not match any of the patterns in patternProperties.
117+
And, patternProperties schemas only apply to keys missing from properties.
105118
106119
:rtype: ``dict``
107120
"""
108-
property_schema = {}
121+
# preserve the order in object_keys
122+
flattened_properties_schema = {key: {} for key in object_keys}
123+
124+
# properties takes precedence over patternProperties and additionalProperties
125+
properties_schema = object_schema.get("properties", {})
126+
flattened_properties_schema.update(properties_schema)
127+
128+
# extra_keys has keys that may use patternProperties or additionalProperties
129+
# we remove keys when they have been assigned a schema
130+
extra_keys = set(object_keys) - set(properties_schema.keys())
131+
132+
if not extra_keys:
133+
# nothing to check. Don't look at patternProperties or additionalProperties.
134+
return flattened_properties_schema
135+
136+
# match each key against patternPropetties
137+
pattern_properties = object_schema.get("patternProperties", {})
138+
# patternProperties should be a dict if defined
139+
if pattern_properties and isinstance(pattern_properties, dict):
140+
# we need to match all extra_keys against all patterns
141+
# and then compose the per-property schema from all
142+
# the matched patterns' properties.
143+
pattern_properties = {
144+
re.compile(raw_pattern): pattern_schema
145+
for raw_pattern, pattern_schema in pattern_properties.items()
146+
}
147+
for key in list(extra_keys):
148+
key_schemas = []
149+
for pattern, pattern_schema in pattern_properties.items():
150+
if pattern.search(key):
151+
key_schemas.append(pattern_schema)
152+
if key_schemas:
153+
# This naive schema composition approximates allOf.
154+
# We can improve this later if someone provides examples that need
155+
# a better allOf schema implementation for patternProperties.
156+
composed_schema = {}
157+
for schema in key_schemas:
158+
composed_schema.update(schema)
159+
# update matched key
160+
flattened_properties_schema[key] = composed_schema
161+
# don't overwrite matched key's schema with additionalProperties
162+
extra_keys.remove(key)
163+
164+
if not extra_keys:
165+
# nothing else to check. Don't look at additionalProperties.
166+
return flattened_properties_schema
167+
168+
# fill in any remaining keys with additionalProperties
109169
additional_properties = object_schema.get("additionalProperties", {})
110170
# additionalProperties can be a boolean or a dict
111171
if additional_properties and isinstance(additional_properties, dict):
112172
# ensure that these keys are present in the object
113-
for key in additional_properties_keys:
114-
property_schema[key] = additional_properties
115-
property_schema.update(object_schema.get("properties", {}))
116-
return property_schema
173+
for key in extra_keys:
174+
flattened_properties_schema[key] = additional_properties
175+
176+
return flattened_properties_schema
177+
178+
@staticmethod
179+
def _get_array_items_schema(array_schema, items_count=0):
180+
"""
181+
Create a schema for array items using both additionalItems and items.
182+
183+
This 'flattens' items and additionalItems so that we can handle additionalItems
184+
as if each additional item was defined in items.
185+
186+
The additionalItems schema will only be used if the items schema is shorter
187+
than items_count. So, when additionalItems is defined, the items schema will be
188+
extended to be at least as long as items_count.
189+
190+
:rtype: ``list``
191+
"""
192+
flattened_items_schema = []
193+
items_schema = array_schema.get("items", [])
194+
if isinstance(items_schema, dict):
195+
# with only one schema for all items, additionalItems will be ignored.
196+
flattened_items_schema.extend([items_schema] * items_count)
197+
else:
198+
# items is a positional array of schemas
199+
flattened_items_schema.extend(items_schema)
200+
201+
flattened_items_schema_count = len(flattened_items_schema)
202+
if flattened_items_schema_count >= items_count:
203+
# no additional items to account for.
204+
return flattened_items_schema
205+
206+
additional_items = array_schema.get("additionalItems", {})
207+
# additionalItems can be a boolean or a dict
208+
if additional_items and isinstance(additional_items, dict):
209+
# ensure that these indexes are present in the array
210+
flattened_items_schema.extend(
211+
[additional_items] * (items_count - flattened_items_schema_count)
212+
)
213+
214+
return flattened_items_schema
117215

118216
def _assign_dynamic_config_values(self, schema, config, parent_keys=None):
119217
"""
@@ -137,7 +235,13 @@ def _assign_dynamic_config_values(self, schema, config, parent_keys=None):
137235
if config_is_dict:
138236
# different schema for each key/value pair
139237
schema_item = schema.get(config_item_key, {})
140-
if config_is_list:
238+
if config_is_list and isinstance(schema, list):
239+
# positional schema for list items
240+
try:
241+
schema_item = schema[config_item_key]
242+
except IndexError:
243+
schema_item = {}
244+
elif config_is_list:
141245
# same schema is shared between every item in the list
142246
schema_item = schema
143247

@@ -149,19 +253,23 @@ def _assign_dynamic_config_values(self, schema, config, parent_keys=None):
149253

150254
# Inspect nested object properties
151255
if is_dictionary:
152-
property_schema = self._get_object_property_schema(
256+
properties_schema = self._get_object_properties_schema(
153257
schema_item,
154-
additional_properties_keys=config_item_value.keys(),
258+
object_keys=config_item_value.keys(),
155259
)
156260
self._assign_dynamic_config_values(
157-
schema=property_schema,
261+
schema=properties_schema,
158262
config=config[config_item_key],
159263
parent_keys=current_keys,
160264
)
161265
# Inspect nested list items
162266
elif is_list:
267+
items_schema = self._get_array_items_schema(
268+
schema_item,
269+
items_count=len(config[config_item_key]),
270+
)
163271
self._assign_dynamic_config_values(
164-
schema=schema_item.get("items", {}),
272+
schema=items_schema,
165273
config=config[config_item_key],
166274
parent_keys=current_keys,
167275
)
@@ -193,35 +301,72 @@ def _assign_default_values(self, schema, config):
193301
194302
Note: This method mutates config argument in place.
195303
196-
:rtype: ``dict``
304+
:rtype: ``dict|list``
197305
"""
198-
for schema_item_key, schema_item in six.iteritems(schema):
306+
schema_is_dict = isinstance(schema, dict)
307+
iterator = schema.items() if schema_is_dict else enumerate(schema)
308+
309+
# _get_*_schema ensures that schema_item is always a dict
310+
for schema_item_key, schema_item in iterator:
199311
has_default_value = "default" in schema_item
200-
has_config_value = schema_item_key in config
312+
if isinstance(config, dict):
313+
has_config_value = schema_item_key in config
314+
else:
315+
has_config_value = schema_item_key < len(config)
201316

202317
default_value = schema_item.get("default", None)
203-
is_object = schema_item.get("type", None) == "object"
204-
has_properties = schema_item.get("properties", None)
205-
has_additional_properties = schema_item.get("additionalProperties", None)
206-
207318
if has_default_value and not has_config_value:
208319
# Config value is not provided, but default value is, use a default value
209320
config[schema_item_key] = default_value
210321

211-
# Inspect nested object properties
212-
if is_object and (has_properties or has_additional_properties):
213-
if not config.get(schema_item_key, None):
214-
config[schema_item_key] = {}
322+
try:
323+
config_value = config[schema_item_key]
324+
except (KeyError, IndexError):
325+
config_value = None
215326

216-
property_schema = self._get_object_property_schema(
217-
schema_item,
218-
additional_properties_keys=config[schema_item_key].keys(),
219-
)
327+
schema_item_type = schema_item.get("type", None)
220328

221-
self._assign_default_values(
222-
schema=property_schema, config=config[schema_item_key]
329+
if schema_item_type == "object":
330+
has_properties = schema_item.get("properties", None)
331+
has_pattern_properties = schema_item.get("patternProperties", None)
332+
has_additional_properties = schema_item.get(
333+
"additionalProperties", None
223334
)
224335

336+
# Inspect nested object properties
337+
if (
338+
has_properties
339+
or has_pattern_properties
340+
or has_additional_properties
341+
):
342+
if not config_value:
343+
config_value = config[schema_item_key] = {}
344+
345+
properties_schema = self._get_object_properties_schema(
346+
schema_item,
347+
object_keys=config_value.keys(),
348+
)
349+
350+
self._assign_default_values(
351+
schema=properties_schema, config=config_value
352+
)
353+
elif schema_item_type == "array":
354+
has_items = schema_item.get("items", None)
355+
has_additional_items = schema_item.get("additionalItems", None)
356+
357+
# Inspect nested array items
358+
if has_items or has_additional_items:
359+
if not config_value:
360+
config_value = config[schema_item_key] = []
361+
362+
items_schema = self._get_array_items_schema(
363+
schema_item,
364+
items_count=len(config_value),
365+
)
366+
self._assign_default_values(
367+
schema=items_schema, config=config_value
368+
)
369+
225370
return config
226371

227372
def _get_datastore_value_for_expression(self, key, value, config_schema_item=None):

0 commit comments

Comments
 (0)