Skip to content

Commit 609b666

Browse files
committed
workaround
1 parent 9747b31 commit 609b666

5 files changed

Lines changed: 139 additions & 17 deletions

File tree

azext_iot/adr/providers/base.py

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88

99
from azure.cli.core.azclierror import AzureResponseError, ResourceNotFoundError
1010
from azure.core.exceptions import HttpResponseError
11+
from azure.core.rest import HttpRequest
1112
from knack.log import get_logger
1213
from rich.console import Console
1314

1415
from azext_iot._factory import adr_service_factory
16+
from azext_iot.constants import LRO_POLL_RETRIES, LRO_POLL_WAIT_SEC
1517
from azext_iot.common.utility import wait_for_terminal_state
1618

1719
__all__ = ["ADRProvider", "console"]
@@ -23,6 +25,36 @@
2325
console = Console()
2426

2527

28+
# ---------------------------------------------------------------------------
29+
# TEMPORARY WORKAROUND — the Device Registry service is under active development.
30+
#
31+
# ADR mutations are long-running operations (LROs). The status URL the service
32+
# returns (via the ``Azure-AsyncOperation`` header) points at the DIRECT
33+
# resource-provider host (``control-plane.prod.<region>.iotadr.net``), NOT at
34+
# ARM. That host currently requires an ARM PoP (proof-of-possession) token that
35+
# neither the Azure SDK poller nor a plain bearer client sends, so polling the
36+
# async-operation URL returns HTTP 500 "ARM PoP token authentication failed"
37+
# even though the mutation itself succeeds (HTTP 202). Per the Device Registry
38+
# team this async-status implementation is incomplete/temporary; until it is
39+
# fixed we poll the resource's own ``provisioningState`` instead.
40+
#
41+
# TO REVERT once the backend is fixed: set POLL_PROVISIONING_STATE_WORKAROUND to
42+
# ``False`` (or delete this block, the workaround branch in ``_await_terminal``
43+
# and ``_poll_provisioning_state``). No command call sites need to change.
44+
# ---------------------------------------------------------------------------
45+
POLL_PROVISIONING_STATE_WORKAROUND = True
46+
47+
# Terminal ARM ``provisioningState`` values.
48+
_PROVISIONING_SUCCEEDED = "Succeeded"
49+
_PROVISIONING_FAILURES = ("Failed", "Canceled")
50+
51+
# Only PUT/PATCH/DELETE LROs address the resource itself, so their initial
52+
# request URL can be GET-polled for ``provisioningState``. POST-style action
53+
# LROs (synchronize, revoke, refresh, run, ...) have no such pollable resource
54+
# URL, so they fall back to the default SDK polling.
55+
_RESOURCE_MUTATION_METHODS = ("PUT", "PATCH", "DELETE")
56+
57+
2658
class ADRProvider(object):
2759
def __init__(self, cmd):
2860
self.cmd = cmd
@@ -35,13 +67,106 @@ def _wait(self, poller, status_message: str, **kwargs):
3567
pop ``no_wait`` from kwargs and return the poller immediately when set,
3668
otherwise show ``status_message`` in a spinner while waiting for the
3769
operation to reach a terminal state. Remaining kwargs are forwarded to
38-
``wait_for_terminal_state`` (e.g. polling interval overrides).
70+
``_await_terminal`` (e.g. polling interval overrides).
3971
"""
4072
no_wait = kwargs.pop("no_wait", False)
4173
if no_wait:
4274
return poller
4375
with console.status(status_message):
44-
return wait_for_terminal_state(poller, **kwargs)
76+
return self._await_terminal(poller, **kwargs)
77+
78+
def _await_terminal(self, poller, **kwargs):
79+
"""Wait for an ADR long-running operation to reach a terminal state.
80+
81+
Single choke point used both by ``_wait`` and by the few commands that
82+
drive their poller directly. While the temporary workaround is enabled we
83+
poll the resource's ``provisioningState`` (see the module note above);
84+
otherwise we defer to the shared SDK helper.
85+
"""
86+
if POLL_PROVISIONING_STATE_WORKAROUND:
87+
return self._poll_provisioning_state(poller, **kwargs)
88+
return wait_for_terminal_state(poller, **kwargs)
89+
90+
@staticmethod
91+
def _poller_initial_request(poller):
92+
"""Best-effort ``(url, method)`` of the mutating request a poller wraps.
93+
94+
That request URL *is* the resource URL, which is exactly what we GET to
95+
read ``provisioningState``. Reaches through SDK internals defensively and
96+
returns ``(None, None)`` when the poller is not shaped as expected, so the
97+
caller can fall back to the default polling.
98+
"""
99+
method = getattr(poller, "_polling_method", None)
100+
if method is None and hasattr(poller, "polling_method"):
101+
# Best-effort only; any failure just falls back to default polling.
102+
try:
103+
method = poller.polling_method()
104+
except Exception: # noqa: BLE001
105+
method = None
106+
initial = getattr(method, "_initial_response", None)
107+
request = getattr(initial, "http_request", None)
108+
if request is None:
109+
request = getattr(getattr(initial, "http_response", None), "request", None)
110+
if request is None:
111+
return None, None
112+
return getattr(request, "url", None), (getattr(request, "method", "") or "").upper()
113+
114+
def _poll_provisioning_state(self, poller, wait_sec: int = LRO_POLL_WAIT_SEC, **_):
115+
"""TEMPORARY: resolve an LRO by polling the resource's ``provisioningState``.
116+
117+
See the module-level workaround note. Steps:
118+
119+
* If the poller already completed inline (e.g. a create/PUT that returns
120+
the resource with a terminal ``provisioningState``), return its result
121+
directly - no async-operation/PoP hop needed.
122+
* Otherwise recover the resource URL from the poller and GET it until
123+
``provisioningState`` is terminal: ``Succeeded`` (or a resource that
124+
exposes no ``provisioningState``) returns the body; ``Failed`` /
125+
``Canceled`` raises ``AzureResponseError``.
126+
127+
For a DELETE a 404 means the resource is gone (success). For any other
128+
method a 404 is treated as "not readable yet" and retried (the by-name
129+
read path can briefly 404 on some backend partitions). Action-style
130+
(POST) LROs and any poller we cannot introspect fall back to the default
131+
SDK polling, so behavior is never worse than before the workaround.
132+
"""
133+
from time import sleep
134+
135+
# Fast path: an LRO that completed inline is already terminal with no
136+
# further network calls, so return its result and skip the PoP-500 hop.
137+
if poller.done():
138+
return poller.result()
139+
140+
url, method = self._poller_initial_request(poller)
141+
if not url or method not in _RESOURCE_MUTATION_METHODS:
142+
# No pollable resource URL (e.g. a POST action) -> default polling.
143+
return wait_for_terminal_state(poller, wait_sec=wait_sec)
144+
145+
is_delete = method == "DELETE"
146+
last_body = None
147+
for _ in range(LRO_POLL_RETRIES):
148+
response = self.client.send_request(HttpRequest("GET", url))
149+
code = response.status_code
150+
if code == 404:
151+
if is_delete:
152+
return None # resource removed -> delete complete
153+
sleep(wait_sec) # not readable yet -> retry
154+
continue
155+
if 200 <= code < 300:
156+
last_body = response.json()
157+
state = (last_body.get("properties") or {}).get("provisioningState")
158+
if state == _PROVISIONING_SUCCEEDED or state is None:
159+
return last_body
160+
if state in _PROVISIONING_FAILURES:
161+
raise AzureResponseError(
162+
f"The operation did not succeed (provisioningState='{state}'). "
163+
"Inspect the service activity log using the operation's correlation id."
164+
)
165+
sleep(wait_sec) # still provisioning (Accepted/Updating/...) -> re-check
166+
continue
167+
# Unexpected status (e.g. a transient 5xx) -> brief backoff and retry.
168+
sleep(wait_sec)
169+
return last_body # budget exhausted -> return last-known body (may be None)
45170

46171
def _raise_if_parent_not_found(self, error: Exception, message: str):
47172
"""Translate a backend "ParentResourceNotFound" 404 into a friendly error.

azext_iot/adr/providers/credential.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212

1313
from azext_iot.adr.common import CREDENTIAL_NOT_FOUND_MSG
1414
from azext_iot.adr.providers.base import ADRProvider, console
15-
from azext_iot.common.utility import wait_for_terminal_state
1615

1716
if TYPE_CHECKING:
1817
from azure.core.polling import LROPoller
@@ -52,7 +51,7 @@ def create(
5251
namespace_name=namespace_name,
5352
resource=resource,
5453
)
55-
result = wait_for_terminal_state(poller, **kwargs)
54+
result = self._await_terminal(poller, **kwargs)
5655
return result
5756

5857
def show(self, namespace_name: str, resource_group_name: str):
@@ -76,15 +75,15 @@ def delete(self, namespace_name: str, resource_group_name: str, **kwargs):
7675
poller = self.client.credentials.begin_delete(
7776
resource_group_name=resource_group_name, namespace_name=namespace_name
7877
)
79-
return wait_for_terminal_state(poller, **kwargs)
78+
return self._await_terminal(poller, **kwargs)
8079

8180
def synchronize(self, namespace_name: str, resource_group_name: str, **kwargs):
8281
with console.status(f"Synchronizing credentials for namespace {namespace_name}..."):
8382
poller: LROPoller = self.client.credentials.begin_synchronize(
8483
resource_group_name=resource_group_name, namespace_name=namespace_name
8584
)
8685
try:
87-
result = wait_for_terminal_state(poller, **kwargs)
86+
result = self._await_terminal(poller, **kwargs)
8887
except HttpResponseError as e:
8988
# The backend returns 200 OK with an empty body when the LRO completes,
9089
# but ARMPolling expects a "status" or "provisioningState" field in the

azext_iot/adr/providers/group.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
GroupType,
2020
)
2121
from azext_iot.adr.providers.base import ADRProvider
22-
from azext_iot.common.utility import wait_for_terminal_state
2322

2423
logger = get_logger(__name__)
2524

@@ -328,7 +327,7 @@ def _cascade_delete_jobs(
328327
namespace_name=namespace_name,
329328
job_name=j["name"],
330329
)
331-
wait_for_terminal_state(poller)
330+
self._await_terminal(poller)
332331

333332
def refresh(
334333
self,

azext_iot/adr/providers/namespace.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
from azext_iot.adr.providers.base import ADRProvider, console
2323
from azext_iot.adr.providers.credential import CredentialProvider
2424
from azext_iot.adr.providers.policy import PolicyProvider
25-
from azext_iot.common.utility import wait_for_terminal_state
2625

2726
logger = get_logger(__name__)
2827

@@ -140,10 +139,11 @@ def create(
140139
)
141140
return poller
142141
with console.status(f"Creating namespace {namespace_name}..."):
143-
namespace_result = wait_for_terminal_state(poller, **kwargs)
142+
namespace_result = self._await_terminal(poller, **kwargs)
144143

145144
# The create response may omit resourceGroup; backfill it from the request input.
146-
if not namespace_result.get("resourceGroup"):
145+
# (namespace_result can be None if the provisioningState poll times out.)
146+
if namespace_result and not namespace_result.get("resourceGroup"):
147147
namespace_result["resourceGroup"] = resource_group_name
148148

149149
if should_create_credential_policy:

azext_iot/adr/providers/policy.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
POLICY_PARENT_RESOURCE_NOT_FOUND_MSG,
1616
)
1717
from azext_iot.adr.providers.base import ADRProvider, console
18-
from azext_iot.common.utility import wait_for_terminal_state
1918

2019

2120
class PolicyProvider(ADRProvider):
@@ -75,7 +74,7 @@ def create(
7574
policy_name=policy_name,
7675
resource=resource,
7776
)
78-
return wait_for_terminal_state(poller, **kwargs)
77+
return self._await_terminal(poller, **kwargs)
7978

8079
def show(self, policy_name: str, namespace_name: str, resource_group_name: str):
8180
# Ensure namespace exists
@@ -120,7 +119,7 @@ def delete(self, policy_name: str, namespace_name: str, resource_group_name: str
120119
namespace_name=namespace_name,
121120
policy_name=policy_name,
122121
)
123-
return wait_for_terminal_state(poller, **kwargs)
122+
return self._await_terminal(poller, **kwargs)
124123

125124
def update(
126125
self,
@@ -154,7 +153,7 @@ def update(
154153
policy_name=policy_name,
155154
properties=resource,
156155
)
157-
wait_for_terminal_state(poller, **kwargs)
156+
self._await_terminal(poller, **kwargs)
158157

159158
# LRO update may return incomplete object; always fetch updated resource
160159
return self.show(
@@ -171,7 +170,7 @@ def revoke_issuer(self, policy_name: str, namespace_name: str, resource_group_na
171170
namespace_name=namespace_name,
172171
policy_name=policy_name,
173172
)
174-
return wait_for_terminal_state(poller, **kwargs)
173+
return self._await_terminal(poller, **kwargs)
175174

176175
def activate_byor(
177176
self,
@@ -192,4 +191,4 @@ def activate_byor(
192191
policy_name=policy_name,
193192
body=body,
194193
)
195-
return wait_for_terminal_state(poller, **kwargs)
194+
return self._await_terminal(poller, **kwargs)

0 commit comments

Comments
 (0)