Merge "Enhance schema loading to support required properties"
This commit is contained in:
47
etc/schema-image.json.extended-format-sample
Normal file
47
etc/schema-image.json.extended-format-sample
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"properties": {
|
||||
"kernel_id": {
|
||||
"type": ["null", "string"],
|
||||
"pattern": "^([0-9a-fA-F]){8}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){12}$",
|
||||
"description": "ID of image stored in Glance that should be used as the kernel when booting an AMI-style image."
|
||||
},
|
||||
"ramdisk_id": {
|
||||
"type": ["null", "string"],
|
||||
"pattern": "^([0-9a-fA-F]){8}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){12}$",
|
||||
"description": "ID of image stored in Glance that should be used as the ramdisk when booting an AMI-style image."
|
||||
},
|
||||
"instance_uuid": {
|
||||
"type": "string",
|
||||
"description": "Metadata which can be used to record which instance this image is associated with. (Informational only, does not create an instance snapshot.)"
|
||||
},
|
||||
"architecture": {
|
||||
"description": "Operating system architecture as specified in https://docs.openstack.org/python-glanceclient/latest/cli/property-keys.html",
|
||||
"type": "string"
|
||||
},
|
||||
"os_distro": {
|
||||
"description": "Common name of operating system distribution as specified in https://docs.openstack.org/python-glanceclient/latest/cli/property-keys.html",
|
||||
"type": "string"
|
||||
},
|
||||
"os_version": {
|
||||
"description": "Operating system version as specified by the distributor.",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "A human-readable string describing this image.",
|
||||
"type": "string"
|
||||
},
|
||||
"cinder_encryption_key_id": {
|
||||
"description": "Identifier in the OpenStack Key Management Service for the encryption key for the Block Storage Service to use when mounting a volume created from this image",
|
||||
"type": "string"
|
||||
},
|
||||
"cinder_encryption_key_deletion_policy": {
|
||||
"description": "States the condition under which the Image Service will delete the object associated with the 'cinder_encryption_key_id' image property. If this property is missing, the Image Service will take no action",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"on_image_deletion",
|
||||
"do_not_delete"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["os_distro", "architecture"]
|
||||
}
|
||||
@@ -2098,12 +2098,31 @@ def _get_base_links():
|
||||
def get_schema(custom_properties=None):
|
||||
properties = get_base_properties()
|
||||
links = _get_base_links()
|
||||
schema = glance.schema.PermissiveSchema('image', properties, links)
|
||||
|
||||
required = None
|
||||
custom_props = None
|
||||
|
||||
if custom_properties:
|
||||
for property_value in custom_properties.values():
|
||||
# Support extended format: {'properties': {...}, 'required': [...]}
|
||||
if (isinstance(custom_properties, dict) and
|
||||
'properties' in custom_properties):
|
||||
required = custom_properties.get('required')
|
||||
# Convert empty required list to None
|
||||
if required is not None and len(required) == 0:
|
||||
required = None
|
||||
custom_props = custom_properties.get('properties', {})
|
||||
else:
|
||||
# Support flat format (backward compatibility)
|
||||
custom_props = custom_properties
|
||||
|
||||
schema = glance.schema.PermissiveSchema(
|
||||
'image', properties, links, required=required
|
||||
)
|
||||
|
||||
if custom_props:
|
||||
for property_value in custom_props.values():
|
||||
property_value['is_base'] = False
|
||||
schema.merge_properties(custom_properties)
|
||||
schema.merge_properties(custom_props)
|
||||
return schema
|
||||
|
||||
|
||||
@@ -2119,18 +2138,48 @@ def get_collection_schema(custom_properties=None):
|
||||
|
||||
|
||||
def load_custom_properties():
|
||||
"""Find the schema properties files and load them into a dict."""
|
||||
"""Find the schema properties files and load them into a dict.
|
||||
|
||||
Supports two formats:
|
||||
1. Extended format (with required support)::
|
||||
|
||||
{"properties": {...}, "required": [...]}
|
||||
|
||||
2. Flat format (backward compatibility)::
|
||||
|
||||
{"property_name": {...}, ...}
|
||||
|
||||
Returns:
|
||||
dict: Either {'properties': {...}, 'required': [...]}
|
||||
for extended format,
|
||||
or {'properties': {...}, 'required': None} for flat format,
|
||||
or {'properties': {}, 'required': None} if file not found.
|
||||
"""
|
||||
filename = 'schema-image.json'
|
||||
match = CONF.find_file(filename)
|
||||
if match:
|
||||
with open(match, 'r') as schema_file:
|
||||
schema_data = schema_file.read()
|
||||
return json.loads(schema_data)
|
||||
data = json.loads(schema_data)
|
||||
|
||||
# Check if it's the extended format with 'properties' key
|
||||
if isinstance(data, dict) and 'properties' in data:
|
||||
# Extended format: {'properties': {...}, 'required': [...]}
|
||||
return {
|
||||
'properties': data.get('properties', {}),
|
||||
'required': data.get('required')
|
||||
}
|
||||
else:
|
||||
# Flat format (backward compatibility): just property definitions
|
||||
return {
|
||||
'properties': data,
|
||||
'required': None
|
||||
}
|
||||
else:
|
||||
msg = (_LW('Could not find schema properties file %s. Continuing '
|
||||
'without custom properties') % filename)
|
||||
LOG.warning(msg)
|
||||
return {}
|
||||
return {'properties': {}, 'required': None}
|
||||
|
||||
|
||||
def create_resource(custom_properties=None):
|
||||
|
||||
@@ -6419,6 +6419,133 @@ class TestImageSchemaDeterminePropertyBasis(test_utils.BaseTestCase):
|
||||
self.assertTrue(schema.properties['disk_format'].get('is_base', True))
|
||||
|
||||
|
||||
class TestImageSchemaWithRequiredProperties(test_utils.BaseTestCase):
|
||||
|
||||
def test_get_schema_flat_format_without_required(self):
|
||||
"""Test flat format (backward compatibility) without required"""
|
||||
custom_properties = {
|
||||
'os_distro': {
|
||||
'type': 'string',
|
||||
'description': 'Operating system distribution'
|
||||
}
|
||||
}
|
||||
schema = glance.api.v2.images.get_schema(custom_properties)
|
||||
|
||||
# Schema should not have required field
|
||||
self.assertIsNone(schema.required)
|
||||
# Custom property should be present
|
||||
self.assertIn('os_distro', schema.properties)
|
||||
self.assertFalse(schema.properties['os_distro'].get('is_base', True))
|
||||
|
||||
def test_get_schema_extended_format_with_multiple_required(self):
|
||||
"""Test extended format with multiple required fields"""
|
||||
custom_properties = {
|
||||
'properties': {
|
||||
'os_distro': {
|
||||
'type': 'string',
|
||||
'description': 'Operating system distribution'
|
||||
},
|
||||
'architecture': {
|
||||
'type': 'string',
|
||||
'description': 'CPU architecture'
|
||||
},
|
||||
'os_version': {
|
||||
'type': 'string',
|
||||
'description': 'OS version'
|
||||
}
|
||||
},
|
||||
'required': ['os_distro', 'architecture']
|
||||
}
|
||||
schema = glance.api.v2.images.get_schema(custom_properties)
|
||||
|
||||
# Schema should have both required fields
|
||||
self.assertIsNotNone(schema.required)
|
||||
self.assertEqual(2, len(schema.required))
|
||||
self.assertIn('os_distro', schema.required)
|
||||
self.assertIn('architecture', schema.required)
|
||||
self.assertNotIn('os_version', schema.required)
|
||||
|
||||
def test_get_schema_extended_format_without_required_key(self):
|
||||
"""Test extended format without required key"""
|
||||
custom_properties = {
|
||||
'properties': {
|
||||
'os_distro': {
|
||||
'type': 'string',
|
||||
'description': 'Operating system distribution'
|
||||
}
|
||||
}
|
||||
}
|
||||
schema = glance.api.v2.images.get_schema(custom_properties)
|
||||
|
||||
# Schema should not have required field when not specified
|
||||
self.assertIsNone(schema.required)
|
||||
self.assertIn('os_distro', schema.properties)
|
||||
|
||||
def test_get_schema_extended_format_with_empty_required(self):
|
||||
"""Test extended format with empty required array"""
|
||||
custom_properties = {
|
||||
'properties': {
|
||||
'os_distro': {
|
||||
'type': 'string',
|
||||
'description': 'Operating system distribution'
|
||||
}
|
||||
},
|
||||
'required': []
|
||||
}
|
||||
schema = glance.api.v2.images.get_schema(custom_properties)
|
||||
|
||||
# Empty required list should result in None
|
||||
self.assertIsNone(schema.required)
|
||||
|
||||
def test_load_custom_properties_flat_format(self):
|
||||
"""Test load_custom_properties with flat format"""
|
||||
with mock.patch.object(CONF, 'find_file') as mock_find:
|
||||
mock_find.return_value = '/etc/glance/schema-image.json'
|
||||
with mock.patch('builtins.open', mock.mock_open(
|
||||
read_data='{"os_distro": {"type": "string"}}')):
|
||||
result = glance.api.v2.images.load_custom_properties()
|
||||
|
||||
self.assertIn('properties', result)
|
||||
self.assertIn('required', result)
|
||||
self.assertIsNotNone(result['properties'])
|
||||
self.assertIsNone(result['required'])
|
||||
self.assertIn('os_distro', result['properties'])
|
||||
|
||||
def test_load_custom_properties_extended_format(self):
|
||||
"""Test load_custom_properties with extended format"""
|
||||
extended_data = '''{
|
||||
"properties": {
|
||||
"os_distro": {"type": "string"},
|
||||
"architecture": {"type": "string"}
|
||||
},
|
||||
"required": ["os_distro"]
|
||||
}'''
|
||||
with mock.patch.object(CONF, 'find_file') as mock_find:
|
||||
mock_find.return_value = '/etc/glance/schema-image.json'
|
||||
with mock.patch('builtins.open', mock.mock_open(
|
||||
read_data=extended_data)):
|
||||
result = glance.api.v2.images.load_custom_properties()
|
||||
|
||||
self.assertIn('properties', result)
|
||||
self.assertIn('required', result)
|
||||
self.assertIsNotNone(result['properties'])
|
||||
self.assertIsNotNone(result['required'])
|
||||
self.assertEqual(['os_distro'], result['required'])
|
||||
self.assertIn('os_distro', result['properties'])
|
||||
self.assertIn('architecture', result['properties'])
|
||||
|
||||
def test_load_custom_properties_file_not_found(self):
|
||||
"""Test load_custom_properties when file is not found"""
|
||||
with mock.patch.object(CONF, 'find_file') as mock_find:
|
||||
mock_find.return_value = None
|
||||
result = glance.api.v2.images.load_custom_properties()
|
||||
|
||||
self.assertIn('properties', result)
|
||||
self.assertIn('required', result)
|
||||
self.assertEqual({}, result['properties'])
|
||||
self.assertIsNone(result['required'])
|
||||
|
||||
|
||||
class TestMultiImagesController(base.MultiIsolatedUnitTest):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
Enhanced the custom property schema file (``schema-image.json``) to
|
||||
support an extended format that allows operators to define required
|
||||
properties. The schema file now supports a structured format with
|
||||
``properties`` and ``required`` keys::
|
||||
|
||||
{
|
||||
"properties": {
|
||||
"os_distro": {
|
||||
"description": "Operating system distribution",
|
||||
"type": "string"
|
||||
},
|
||||
"architecture": {
|
||||
"description": "Operating system architecture",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["os_distro", "architecture"]
|
||||
}
|
||||
|
||||
When using this extended format, properties listed in the ``required``
|
||||
array will be validated as mandatory fields during image creation. This
|
||||
allows operators to enforce that specific custom properties are provided
|
||||
when images are created, improving data quality and consistency.
|
||||
|
||||
Backwards compatibility is fully maintained. The original flat format
|
||||
(with property definitions at the top level) continues to work as before,
|
||||
and existing ``schema-image.json`` files do not need to be modified. The
|
||||
schema loading logic automatically detects which format is being used.
|
||||
|
||||
A sample extended format schema file is provided at
|
||||
``etc/schema-image.json.extended-format-sample``.
|
||||
Reference in New Issue
Block a user