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
+2 -2
View File
@@ -1,8 +1,8 @@
id: ovhcloud id: ovhcloud
name: OVHcloud name: OVHcloud
version: 1.2.2 version: 1.2.3
description: "OVHcloud (API v1) — cloud account and infrastructure visibility, plus VoIP on-call routing: read the account, list public cloud projects, list/read dedicated servers, and hold Riposte's on-call ladder in a telephony call queue so a client number reaches whoever is on duty without any channel on your own PBX. Signed application-key authentication; stdlib-only, no extra Python dependencies. (French vendor.)" description: "OVHcloud (API v1) — cloud account and infrastructure visibility, plus VoIP on-call routing: read the account, list public cloud projects, list/read dedicated servers, and hold Riposte's on-call ladder in a telephony call queue so a client number reaches whoever is on duty without any channel on your own PBX. Signed application-key authentication; stdlib-only, no extra Python dependencies. (French vendor.)"
changelog: "1.2.2 — Find the call queue under either product. OVH files a queue under easyHunting or under ovhPabx depending on the number's offer, and the control panel says \"File d'appels\" for both — so assuming the first answered 404 \"This service does not exist\", which sounds like a wrong number and is in fact a wrong product. The product is now discovered once per run, and a service found under neither says which names DO exist. | 1.2.1 — The Test button now probes what the instance is FOR: with telephony coordinates filled in it reads the call queue, which proves the credentials, the IAM policy, the billing account, the service and the queue at once. It used to read /me unconditionally, so a service account correctly narrowed to /telephony failed its own test with a 403 on account:apiovh:me/get — credentials that were perfect, reported as broken. | 1.2.0 — Accept a SERVICE ACCOUNT (OAuth2 client credentials) as well as the historical application keys, because that is what the control panel offers on accounts where application keys are not available. Which scheme is used is decided by what you filled in, not by a mode selector, and an existing instance keeps working untouched. The API base is corrected to /v1 for a service account, since a Bearer token sent to the legacy /1.0 spelling fails in a way that reads as bad credentials. | 1.1.0 — On-call routing: set_oncall_ladder / get_oncall_ladder / list_calls, backed by a VoIP call queue (easyHunting). Riposte publishes the ordered on-call numbers and the carrier rings them in that order, so an inbound client call never crosses your own trunk and spends no simultaneous call on it. Needs the three telephony_* config fields; every other command is unchanged. | 1.0.0 — Initial release: get account, list cloud projects, list/get dedicated servers." changelog: "1.2.3 — The Test button now WALKS to the queue one step at a time and reports what it saw at each: the billing accounts the credentials can reach, the services each product holds, the queues in the one chosen. A bare 404 from this API means a wrong account, a wrong service name, the other product, or a number nobody configured — four jobs, one message — and none of them could be told apart from Riposte. It also answers its own version, because when a test fails identically twice the first question is whether the new code is the code that ran. | 1.2.2 — Find the call queue under either product. OVH files a queue under easyHunting or under ovhPabx depending on the number's offer, and the control panel says \"File d'appels\" for both — so assuming the first answered 404 \"This service does not exist\", which sounds like a wrong number and is in fact a wrong product. The product is now discovered once per run, and a service found under neither says which names DO exist. | 1.2.1 — The Test button now probes what the instance is FOR: with telephony coordinates filled in it reads the call queue, which proves the credentials, the IAM policy, the billing account, the service and the queue at once. It used to read /me unconditionally, so a service account correctly narrowed to /telephony failed its own test with a 403 on account:apiovh:me/get — credentials that were perfect, reported as broken. | 1.2.0 — Accept a SERVICE ACCOUNT (OAuth2 client credentials) as well as the historical application keys, because that is what the control panel offers on accounts where application keys are not available. Which scheme is used is decided by what you filled in, not by a mode selector, and an existing instance keeps working untouched. The API base is corrected to /v1 for a service account, since a Bearer token sent to the legacy /1.0 spelling fails in a way that reads as bad credentials. | 1.1.0 — On-call routing: set_oncall_ladder / get_oncall_ladder / list_calls, backed by a VoIP call queue (easyHunting). Riposte publishes the ordered on-call numbers and the carrier rings them in that order, so an inbound client call never crosses your own trunk and spends no simultaneous call on it. Needs the three telephony_* config fields; every other command is unchanged. | 1.0.0 — Initial release: get account, list cloud projects, list/get dedicated servers."
category: cloud category: cloud
# Per-instance configuration. Requests are signed (SHA-1) with the application # Per-instance configuration. Requests are signed (SHA-1) with the application
+105 -26
View File
@@ -225,37 +225,116 @@ def _queue_id(cfg):
# ── the probe ─────────────────────────────────────────────────────────────── # ── 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 # Two rounds were lost to this endpoint answering a bare 404 — "This service does
# created for on-call routing has no business reading the account — a service # not exist" — which is true of a wrong billing account, a wrong service name, a
# account narrowed to /telephony, which is exactly what it should be narrowed to, # queue filed under the other product, and a number nobody configured yet. Four
# answers 403 "User not granted for this request" on account:apiovh:me/get. The # different jobs, one message, and no way to tell them apart from Riposte.
# 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.
# #
# So: telephony coordinates configured means the telephony probe. That one is worth # So each step is taken separately and REPORTS WHAT IT SAW: the accounts the
# more than /me ever was — it proves the credentials, the IAM policy, the billing # credentials can reach, the services each product holds, the queues in the one we
# account, the service AND the queue in a single call. # want. A failure names the step and lists the values that do exist, which is the
def main(cfg, inputs): # 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() ba = str(cfg.get("telephony_billing_account") or "").strip()
service = str(cfg.get("telephony_service") or "").strip() service = str(cfg.get("telephony_service") or "").strip()
scheme = "service account" if _oauth(cfg) else "application key" if not (ba and service):
if ba and service: resp = request("GET", "/me", cfg)
product = _product(cfg) out.update({"ok": True, "nichandle": resp.get("nichandle")})
queues = request("GET", _base(cfg) + "/hunting/queue", cfg) return out
if not isinstance(queues, list) or not queues:
# Reached the API and found the service, but it holds no queue. NOT a # 1. What can these credentials see at all? It proves the token AND the IAM
# failure of the credentials, and saying so plainly is the difference # policy in one call, and names the accounts — so a mistyped billing account
# between "go and create a queue" and an evening spent on authentication. # is visible rather than inferred.
return {"ok": True, "auth": scheme, "telephony": True, "product": product, "queues": 0, 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. " "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."} "Open the number's configuration once and save it."})
return {"ok": True, "auth": scheme, "telephony": True, "product": product, return out
"queues": len(queues), "queue_ids": [str(q) for q in queues]} out.update({"ok": True, "queues": len(queues)})
resp = request("GET", "/me", cfg) return out
return {"ok": True, "auth": scheme, "nichandle": resp.get("nichandle")}
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) _run(main)