Skip to content

Commit 5386966

Browse files
feat: add linked-hub validation and mixed endpoint warnings (#822)
* feat: add linked-hub validation and mixed endpoint warnings * precise hostname check --------- Co-authored-by: Yuelin Zhao <yuelinzhao@microsoft.com>
1 parent ba818e0 commit 5386966

3 files changed

Lines changed: 111 additions & 13 deletions

File tree

azext_iot/core/_params.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,12 +147,12 @@ def load_arguments(self, _): # pylint: disable=too-many-statements
147147
'Required when authentication type is UserAssigned.')
148148
c.argument('hostname_type',
149149
options_list=['--hostname-type', '--ht'],
150-
arg_type=get_enum_type(["device", "classic"]),
151-
default="device",
150+
arg_type=get_enum_type(["auto", "device", "classic"]),
151+
default="auto",
152152
help="Type of IoT Hub hostname to use when linking. "
153-
"'device' uses the TLS 1.3 device hostname (hub.device.azure-devices.net). "
153+
"'auto' uses the TLS 1.3 device hostname if available, classic otherwise. "
154+
"'device' uses the TLS 1.3 device hostname (errors if not GWv2). "
154155
"'classic' uses the classic hostname (hub.azure-devices.net). "
155-
"Falls back to classic if the hub does not support TLS 1.3. "
156156
"Only applies when --hub-name is provided.")
157157
c.argument('apply_allocation_policy',
158158
help='A boolean indicating whether to apply allocation policy to the IoT hub.',

azext_iot/core/custom.py

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,48 @@ def _get_resource_group_from_hub(hub):
8282
return hub["resourcegroup"]
8383

8484

85-
def _resolve_linked_hub_hostname(hub, hostname_type="device"):
85+
def _resolve_linked_hub_hostname(hub, hostname_type="auto"):
8686
"""Resolve IoT Hub hostname for DPS linked hub based on hostname type."""
87-
if hostname_type == "device":
88-
return hub["properties"].get("deviceHostName") or hub["properties"]["hostName"]
89-
return hub["properties"]["hostName"]
87+
if hostname_type == "classic":
88+
return hub["properties"]["hostName"]
89+
device_hostname = hub["properties"].get("deviceHostName")
90+
if hostname_type == "device" and not device_hostname:
91+
hub_name = hub.get("name", "unknown")
92+
raise InvalidArgumentValueError(
93+
f"The device hostname is not available for IoT Hub '{hub_name}'. "
94+
"This hostname type is only supported on GWv2 IoT Hubs. "
95+
"Use '--hostname-type classic' or '--hostname-type auto' instead."
96+
)
97+
# "auto" or "device" with available deviceHostName
98+
return device_hostname or hub["properties"]["hostName"]
99+
100+
101+
def _warn_mixed_endpoint_types(linked_hubs):
102+
"""Warn if DPS dynamic allocation references hubs with mixed hostname types."""
103+
types = set()
104+
for hub in linked_hubs:
105+
# Only check hubs participating in allocation
106+
if hub.get("applyAllocationPolicy") is False:
107+
continue
108+
hostname = hub.get("hostName", "")
109+
if not hostname:
110+
cs = hub.get("connectionString", "")
111+
for part in cs.split(";"):
112+
if part.lower().startswith("hostname="):
113+
hostname = part.split("=", 1)[1]
114+
break
115+
if not hostname:
116+
hostname = hub.get("name", "")
117+
parts = hostname.split(".")
118+
if len(parts) > 1 and parts[1] == "device":
119+
types.add("device")
120+
elif hostname:
121+
types.add("classic")
122+
if len(types) > 1:
123+
logger.warning(
124+
"DPS has linked hubs with mixed hostname types (device and classic). "
125+
"This may cause inconsistent behavior during device provisioning."
126+
)
90127

91128

92129
# CUSTOM METHODS FOR DPS
@@ -340,7 +377,7 @@ def iot_dps_linked_hub_create(
340377
resource_group_name=None,
341378
authentication_type=None,
342379
user_assigned_identity=None,
343-
hostname_type="device",
380+
hostname_type="auto",
344381
apply_allocation_policy=None,
345382
allocation_weight=None,
346383
no_wait=False
@@ -443,6 +480,9 @@ def iot_dps_linked_hub_create(
443480

444481
dps["properties"]["iotHubs"].append(linked_hub_entry)
445482

483+
# Warn if linked hubs have mixed hostname types (device + classic)
484+
_warn_mixed_endpoint_types(dps["properties"]["iotHubs"])
485+
446486
if no_wait:
447487
return client.iot_dps_resource.begin_create_or_update(
448488
resource_group_name=resource_group_name, provisioning_service_name=dps_name, iot_dps_description=dps

azext_iot/tests/dps/core/test_dps_linked_hub_unit.py

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,36 @@
1010
MutuallyExclusiveArgumentError,
1111
RequiredArgumentMissingError,
1212
)
13-
from azext_iot.core.custom import _resolve_linked_hub_hostname
13+
from azext_iot.core.custom import _resolve_linked_hub_hostname, _warn_mixed_endpoint_types
1414

1515

1616
class TestResolveLinkedHubHostname:
1717
def test_device_with_tls13(self):
1818
hub = {"properties": {"deviceHostName": "hub.device.azure-devices.net", "hostName": "hub.azure-devices.net"}}
1919
assert _resolve_linked_hub_hostname(hub, "device") == "hub.device.azure-devices.net"
2020

21-
def test_device_fallback_to_classic(self):
21+
def test_device_errors_on_v1_hub(self):
22+
hub = {"properties": {"hostName": "hub.azure-devices.net"}, "name": "hub"}
23+
with pytest.raises(InvalidArgumentValueError, match="device hostname is not available"):
24+
_resolve_linked_hub_hostname(hub, "device")
25+
26+
def test_auto_fallback_to_classic(self):
2227
hub = {"properties": {"hostName": "hub.azure-devices.net"}}
23-
assert _resolve_linked_hub_hostname(hub, "device") == "hub.azure-devices.net"
28+
assert _resolve_linked_hub_hostname(hub, "auto") == "hub.azure-devices.net"
29+
30+
def test_auto_uses_device_when_available(self):
31+
hub = {"properties": {"deviceHostName": "hub.device.azure-devices.net", "hostName": "hub.azure-devices.net"}}
32+
assert _resolve_linked_hub_hostname(hub, "auto") == "hub.device.azure-devices.net"
2433

2534
def test_classic(self):
2635
hub = {"properties": {"deviceHostName": "hub.device.azure-devices.net", "hostName": "hub.azure-devices.net"}}
2736
assert _resolve_linked_hub_hostname(hub, "classic") == "hub.azure-devices.net"
2837

29-
def test_default_is_device(self):
38+
def test_default_is_auto(self):
3039
hub = {"properties": {"deviceHostName": "hub.device.azure-devices.net", "hostName": "hub.azure-devices.net"}}
3140
assert _resolve_linked_hub_hostname(hub) == "hub.device.azure-devices.net"
41+
hub_v1 = {"properties": {"hostName": "hub.azure-devices.net"}}
42+
assert _resolve_linked_hub_hostname(hub_v1) == "hub.azure-devices.net"
3243

3344

3445
class TestLinkedHubCreateValidation:
@@ -111,3 +122,50 @@ def test_mi_null_identity_on_dps(self, fixture_cmd, mock_deps, mocker):
111122
cmd=fixture_cmd, client=mock_deps, dps_name="dps",
112123
hub_name="hub", authentication_type="SystemAssigned"
113124
)
125+
126+
127+
class TestMixedEndpointWarning:
128+
def test_no_warning_all_device(self, caplog):
129+
hubs = [
130+
{"name": "hub1.device.azure-devices.net"},
131+
{"name": "hub2.device.azure-devices.net"},
132+
]
133+
_warn_mixed_endpoint_types(hubs)
134+
assert "mixed hostname types" not in caplog.text
135+
136+
def test_no_warning_all_classic(self, caplog):
137+
hubs = [
138+
{"name": "hub1.azure-devices.net"},
139+
{"name": "hub2.azure-devices.net"},
140+
]
141+
_warn_mixed_endpoint_types(hubs)
142+
assert "mixed hostname types" not in caplog.text
143+
144+
def test_warning_on_mixed(self, caplog):
145+
import logging
146+
with caplog.at_level(logging.WARNING):
147+
hubs = [
148+
{"name": "hub1.device.azure-devices.net"},
149+
{"name": "hub2.azure-devices.net"},
150+
]
151+
_warn_mixed_endpoint_types(hubs)
152+
assert "mixed hostname types" in caplog.text
153+
154+
def test_warning_on_mixed_with_connection_string(self, caplog):
155+
import logging
156+
with caplog.at_level(logging.WARNING):
157+
hubs = [
158+
{"name": "hub1.device.azure-devices.net"},
159+
{"connectionString": "HostName=hub2.azure-devices.net;SharedAccessKeyName=x;SharedAccessKey=y"},
160+
]
161+
_warn_mixed_endpoint_types(hubs)
162+
assert "mixed hostname types" in caplog.text
163+
164+
def test_no_warning_single_hub(self, caplog):
165+
hubs = [{"name": "hub1.device.azure-devices.net"}]
166+
_warn_mixed_endpoint_types(hubs)
167+
assert "mixed hostname types" not in caplog.text
168+
169+
def test_no_warning_empty(self, caplog):
170+
_warn_mixed_endpoint_types([])
171+
assert "mixed hostname types" not in caplog.text

0 commit comments

Comments
 (0)