]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
python-common/hostspec: normalize hostnames for case-insensitive matching
authorShubha Jain <SHUBHA.JAIN1@ibm.com>
Tue, 6 Jan 2026 09:16:19 +0000 (14:46 +0530)
committerShubha Jain <SHUBHA.JAIN1@ibm.com>
Fri, 17 Apr 2026 11:45:10 +0000 (17:15 +0530)
Normalize HostSpec and related placement parsing to ensure hostnames
are treated case-insensitively when supplied via service specs.

This prevents service placement failures caused by mixed-case hostnames.

Fixes: https://tracker.ceph.com/issues/74299
Signed-off-by: Shubha Jain <SHUBHA.JAIN1@ibm.com>
src/python-common/ceph/deployment/hostspec.py
src/python-common/ceph/deployment/service_spec.py
src/python-common/ceph/tests/test_hostspec.py

index c73c818bfacb5b44ee31b2ff1b1bd21033abcff9..64ba55c785819bdb79b649d0afec3490df0e6b91 100644 (file)
@@ -4,6 +4,11 @@ import re
 from typing import Optional, List, Any, Dict
 
 
+def normalize_hostname(hostname: str) -> str:
+    """Normalize hostname to lowercase for case-insensitive matching."""
+    return hostname.lower()
+
+
 def assert_valid_host(name: str) -> None:
     p = re.compile('^[a-zA-Z0-9-]+$')
     if len(name) > 250:
@@ -56,16 +61,16 @@ class HostSpec(object):
         self.service_type = 'host'
 
         #: the bare hostname on the host. Not the FQDN.
-        self.hostname = hostname  # type: str
+        self.hostname = normalize_hostname(hostname)
 
         #: DNS name or IP address to reach it
-        self.addr = addr or hostname  # type: str
+        self.addr = addr or normalize_hostname(hostname)
 
         #: label(s), if any
-        self.labels = labels or []  # type: List[str]
+        self.labels = labels or []
 
         #: human readable status
-        self.status = status or ''  # type: str
+        self.status = status or ''
 
         self.location = location
 
@@ -106,6 +111,8 @@ class HostSpec(object):
 
     @staticmethod
     def normalize_json(host_spec: dict) -> dict:
+        if 'hostname' in host_spec:
+            host_spec['hostname'] = normalize_hostname(host_spec['hostname'])
         labels = host_spec.get('labels')
         if labels is not None:
             if isinstance(labels, str):
index 93d30264eeb458c1fcfd1e8a28e529914170a1ed..1922c614d251b0ac8fdb33ba012681bc2318c886 100644 (file)
@@ -33,7 +33,7 @@ from typing import (
 
 import yaml
 
-from ceph.deployment.hostspec import HostSpec, SpecValidationError, assert_valid_host
+from ceph.deployment.hostspec import HostSpec, SpecValidationError, normalize_hostname, assert_valid_host
 from ceph.deployment.utils import unwrap_ipv6, valid_addr, verify_non_negative_int
 from ceph.deployment.utils import verify_positive_int, verify_non_negative_number
 from ceph.deployment.utils import verify_boolean, verify_enum, verify_int
@@ -112,6 +112,11 @@ class HostPlacementSpec(NamedTuple):
     network: str
     name: str
 
+    @classmethod
+    def normalized(cls, hostname: str, network: str = '', name: str = '') -> 'HostPlacementSpec':
+        """Create a HostPlacementSpec with normalized hostname."""
+        return cls(normalize_hostname(hostname), network, name)
+
     def __str__(self) -> str:
         res = ''
         res += self.hostname
@@ -126,6 +131,13 @@ class HostPlacementSpec(NamedTuple):
     def from_json(cls, data: Union[dict, str]) -> 'HostPlacementSpec':
         if isinstance(data, str):
             return cls.parse(data)
+        # Use normalized() for consistent lowercasing
+        if isinstance(data, dict):
+            return cls.normalized(
+                data.get('hostname', ''),
+                data.get('network', ''),
+                data.get('name', '')
+            )
         return cls(**data)
 
     def to_json(self) -> str:
@@ -159,7 +171,8 @@ class HostPlacementSpec(NamedTuple):
 
         match_host = re.search(host_re, host)
         if match_host:
-            host_spec = host_spec._replace(hostname=match_host.group(1))
+            # Lowercase for case-insensitive matching
+            host_spec = host_spec._replace(hostname=normalize_hostname(match_host.group(1)))
 
         name_match = re.search(name_re, host)
         if name_match:
@@ -220,16 +233,18 @@ class HostPattern():
         self.pattern_type: PatternType = pattern_type
         self.compiled_regex = None
         if self.pattern_type == PatternType.regex and self.pattern:
-            self.compiled_regex = re.compile(self.pattern)
+            self.compiled_regex = re.compile(self.pattern, re.IGNORECASE)
 
     def filter_hosts(self, hosts: List[str]) -> List[str]:
         if not self.pattern:
             return []
         if not self.pattern_type or self.pattern_type == PatternType.fnmatch:
-            return fnmatch.filter(hosts, self.pattern)
+            # Case-insensitive fnmatch comparison
+            pattern_lower = self.pattern.lower()
+            return [h for h in hosts if fnmatch.fnmatch(h.lower(), pattern_lower)]
         elif self.pattern_type == PatternType.regex:
             if not self.compiled_regex:
-                self.compiled_regex = re.compile(self.pattern)
+                self.compiled_regex = re.compile(self.pattern, re.IGNORECASE)
             return [h for h in hosts if re.match(self.compiled_regex, h)]
         raise SpecValidationError(f'Got unexpected pattern_type: {self.pattern_type}')
 
@@ -355,8 +370,13 @@ class PlacementSpec(object):
     def set_hosts(self, hosts: Union[List[str], List[HostPlacementSpec]]) -> None:
         # To backpopulate the .hosts attribute when using labels or count
         # in the orchestrator backend.
-        if all([isinstance(host, HostPlacementSpec) for host in hosts]):
-            self.hosts = hosts  # type: ignore
+        if all(isinstance(host, HostPlacementSpec) for host in hosts):
+            # Type narrowing: all items are HostPlacementSpec
+            placement_hosts = cast(List[HostPlacementSpec], hosts)
+            self.hosts = [
+                HostPlacementSpec.normalized(h.hostname, h.network, h.name)
+                for h in placement_hosts
+            ]
         else:
             self.hosts = [HostPlacementSpec.parse(x, require_network=False)  # type: ignore
                           for x in hosts if x]
index b6817579e19803b2c72c8678543304b9fcd7a240..0c35e490fec5d7985ac2c934a7c50e24fcd53d67 100644 (file)
@@ -37,4 +37,12 @@ def test_parse_host_specs(test_input, expected):
 def test_parse_host_specs(bad_input):
     with pytest.raises(SpecValidationError):
         hs = HostSpec.from_json(bad_input)
+
+
+def test_hostname_case_insensitive():
+    # Test hostname is lowercased
+    hs = HostSpec(hostname="Ceph-Node-00")
+    assert hs.hostname == "ceph-node-00"
     
+    hs2 = HostSpec.from_json({"hostname": "MyHost"})
+    assert hs2.hostname == "myhost"