diff --git a/etc/schema-image.json.extended-format-sample b/etc/schema-image.json.extended-format-sample new file mode 100644 index 0000000000..d3871b889c --- /dev/null +++ b/etc/schema-image.json.extended-format-sample @@ -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"] +} diff --git a/glance/api/v2/images.py b/glance/api/v2/images.py index 8d25b2038b..8098159464 100644 --- a/glance/api/v2/images.py +++ b/glance/api/v2/images.py @@ -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): diff --git a/glance/tests/unit/v2/test_images_resource.py b/glance/tests/unit/v2/test_images_resource.py index 2336655764..d857f01eb8 100644 --- a/glance/tests/unit/v2/test_images_resource.py +++ b/glance/tests/unit/v2/test_images_resource.py @@ -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): diff --git a/releasenotes/notes/enhanced-schema-required-properties-276cdaef3a57417d.yaml b/releasenotes/notes/enhanced-schema-required-properties-276cdaef3a57417d.yaml new file mode 100644 index 0000000000..fa07be6cdf --- /dev/null +++ b/releasenotes/notes/enhanced-schema-required-properties-276cdaef3a57417d.yaml @@ -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``.