Merge "Enhance schema loading to support required properties"

This commit is contained in:
Zuul
2026-01-28 19:05:33 +00:00
committed by Gerrit Code Review
4 changed files with 263 additions and 6 deletions

View 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"]
}

View File

@@ -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):

View File

@@ -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):

View File

@@ -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``.