fix(ovhcloud): make the test say where it stopped (1.2.3)
This endpoint answers a bare 404 — "This service does not exist" — for a wrong billing account, a wrong service name, a queue filed under the other product, and a number nobody has configured yet. Four different jobs, one message, and nothing on the Riposte screen could tell them apart. Two rounds went to guessing which one it was. The probe now walks there one step at a time and reports what it saw at each: the billing accounts these credentials can reach, the services each product holds, the queues in the chosen one. A failure names the step and lists the values that do exist, which is what somebody can act on. It also answers its own VERSION. When a test fails identically twice, the first question is whether the new code is the code that ran, and nothing on the screen could answer it. And a failed diagnosis now exits non-zero with an "error" key. Returning ok:false on exit 0 would have been worse than the 404 it replaces: the screen would call the instance healthy while naming the step where it is not. Verified end to end through the real entry point for six shapes — success under either product, unreachable account, wrong account, number that is not a queue, service with no queue, and a listing the IAM policy refuses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj
This commit is contained in:
@@ -225,37 +225,116 @@ def _queue_id(cfg):
|
||||
|
||||
# ── the probe ───────────────────────────────────────────────────────────────
|
||||
#
|
||||
# IT TESTS WHAT THIS INSTANCE IS FOR, which is not always the same thing.
|
||||
# IT TESTS WHAT THIS INSTANCE IS FOR, and it walks there ONE STEP AT A TIME.
|
||||
#
|
||||
# It used to call /me, unconditionally. That reads the ACCOUNT, and an instance
|
||||
# created for on-call routing has no business reading the account — a service
|
||||
# account narrowed to /telephony, which is exactly what it should be narrowed to,
|
||||
# answers 403 "User not granted for this request" on account:apiovh:me/get. The
|
||||
# credentials were perfect and the test said they were not, which is the worst
|
||||
# thing a test button can do: it sends somebody off to regenerate a secret that was
|
||||
# never wrong.
|
||||
# Two rounds were lost to this endpoint answering a bare 404 — "This service does
|
||||
# not exist" — which is true of a wrong billing account, a wrong service name, a
|
||||
# queue filed under the other product, and a number nobody configured yet. Four
|
||||
# different jobs, one message, and no way to tell them apart from Riposte.
|
||||
#
|
||||
# So: telephony coordinates configured means the telephony probe. That one is worth
|
||||
# more than /me ever was — it proves the credentials, the IAM policy, the billing
|
||||
# account, the service AND the queue in a single call.
|
||||
def main(cfg, inputs):
|
||||
# So each step is taken separately and REPORTS WHAT IT SAW: the accounts the
|
||||
# credentials can reach, the services each product holds, the queues in the one we
|
||||
# want. A failure names the step and lists the values that do exist, which is the
|
||||
# thing somebody can act on.
|
||||
#
|
||||
# It also answers its own VERSION. When a test fails identically twice, the first
|
||||
# question is whether the new code is the code that ran, and nothing on the screen
|
||||
# could answer it.
|
||||
VERSION = "1.2.3"
|
||||
|
||||
|
||||
def _try(step, fn):
|
||||
"""Run one step, turning any failure into a named one."""
|
||||
try:
|
||||
return fn(), None
|
||||
except urllib.error.HTTPError as e:
|
||||
return None, "%s: HTTP %d %s" % (step, e.code, e.read().decode("utf-8", "replace")[:200])
|
||||
except Exception as e:
|
||||
return None, "%s: %s" % (step, e)
|
||||
|
||||
|
||||
def _walk(cfg, inputs):
|
||||
scheme = "service account" if _oauth(cfg) else "application key"
|
||||
out = {"version": VERSION, "auth": scheme, "endpoint": _endpoint(cfg)}
|
||||
|
||||
ba = str(cfg.get("telephony_billing_account") or "").strip()
|
||||
service = str(cfg.get("telephony_service") or "").strip()
|
||||
scheme = "service account" if _oauth(cfg) else "application key"
|
||||
if ba and service:
|
||||
product = _product(cfg)
|
||||
queues = request("GET", _base(cfg) + "/hunting/queue", cfg)
|
||||
if not isinstance(queues, list) or not queues:
|
||||
# Reached the API and found the service, but it holds no queue. NOT a
|
||||
# failure of the credentials, and saying so plainly is the difference
|
||||
# between "go and create a queue" and an evening spent on authentication.
|
||||
return {"ok": True, "auth": scheme, "telephony": True, "product": product, "queues": 0,
|
||||
if not (ba and service):
|
||||
resp = request("GET", "/me", cfg)
|
||||
out.update({"ok": True, "nichandle": resp.get("nichandle")})
|
||||
return out
|
||||
|
||||
# 1. What can these credentials see at all? It proves the token AND the IAM
|
||||
# policy in one call, and names the accounts — so a mistyped billing account
|
||||
# is visible rather than inferred.
|
||||
accounts, err = _try("listing telephony billing accounts", lambda: request("GET", "/telephony", cfg))
|
||||
if err:
|
||||
out.update({"ok": False, "step": "billing accounts", "detail": err})
|
||||
return out
|
||||
accounts = [str(a) for a in (accounts or [])]
|
||||
out["billing_accounts"] = accounts
|
||||
if ba not in accounts:
|
||||
out.update({"ok": False, "step": "billing account",
|
||||
"detail": "%s is not one of the accounts these credentials can see: [%s]"
|
||||
% (ba, ", ".join(accounts) or "none")})
|
||||
return out
|
||||
|
||||
# 2. Which product holds the queue, and under what name. Both are listed even on
|
||||
# success: seeing the OTHER product empty is what tells somebody their number
|
||||
# was never configured as a queue at all.
|
||||
services = {}
|
||||
for product in _PRODUCTS:
|
||||
names, err = _try("listing " + product,
|
||||
lambda p=product: request("GET", "/telephony/" + urllib.parse.quote(ba, safe="") + "/" + p, cfg))
|
||||
services[product] = [str(n) for n in (names or [])] if not err else err
|
||||
out["services"] = services
|
||||
product = None
|
||||
for name in _PRODUCTS:
|
||||
if isinstance(services[name], list) and service in services[name]:
|
||||
product = name
|
||||
break
|
||||
if product is None:
|
||||
out.update({"ok": False, "step": "service",
|
||||
"detail": "%s is not a call queue on %s. Configure the number as a call queue in the "
|
||||
"control panel, or set telephony_service to one of the names listed above." % (service, ba)})
|
||||
return out
|
||||
out["product"] = product
|
||||
_CACHE["product"] = product
|
||||
|
||||
# 3. The queue itself.
|
||||
queues, err = _try("listing queues", lambda: request("GET", _base(cfg) + "/hunting/queue", cfg))
|
||||
if err:
|
||||
out.update({"ok": False, "step": "queues", "detail": err})
|
||||
return out
|
||||
queues = [str(q) for q in (queues or [])]
|
||||
out["queue_ids"] = queues
|
||||
if not queues:
|
||||
# Reached the API and found the service, but it holds no queue. NOT a
|
||||
# credential failure, and saying so plainly is the difference between "go and
|
||||
# create a queue" and an evening spent on authentication.
|
||||
out.update({"ok": True, "queues": 0,
|
||||
"detail": "Authenticated, and the service was found, but it holds no call queue yet. "
|
||||
"Create one in the control panel before publishing an on-call ladder."}
|
||||
return {"ok": True, "auth": scheme, "telephony": True, "product": product,
|
||||
"queues": len(queues), "queue_ids": [str(q) for q in queues]}
|
||||
resp = request("GET", "/me", cfg)
|
||||
return {"ok": True, "auth": scheme, "nichandle": resp.get("nichandle")}
|
||||
"Open the number's configuration once and save it."})
|
||||
return out
|
||||
out.update({"ok": True, "queues": len(queues)})
|
||||
return out
|
||||
|
||||
|
||||
def main(cfg, inputs):
|
||||
"""Print the walk, and FAIL when it did not get there.
|
||||
|
||||
A diagnosis that exits 0 is reported as a success, which is worse than the bare
|
||||
404 it replaces: the screen would say the instance works while naming the step
|
||||
where it does not. The "error" key is the marketplace's own convention and the
|
||||
exit code is what the Test button reads — both are set, so no consumer has to
|
||||
interpret the dictionary to know.
|
||||
"""
|
||||
out = _walk(cfg, inputs)
|
||||
if not out.get("ok"):
|
||||
out["error"] = out.get("detail") or ("failed at: " + str(out.get("step") or "an unnamed step"))
|
||||
print(json.dumps(out))
|
||||
sys.exit(1)
|
||||
return out
|
||||
|
||||
|
||||
_run(main)
|
||||
|
||||
Reference in New Issue
Block a user