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:
2026-09-19 19:21:54 +00:00
parent d682d66f04
commit 3ca2d6ded2
2 changed files with 107 additions and 28 deletions
+105 -26
View File
@@ -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)