fix(ovhcloud): test what the instance is for, not the account (1.2.1)
An instance created for on-call routing has no business reading the account, and a service account narrowed to /telephony — which is exactly how it should be narrowed — answers 403 "User not granted for this request" on account:apiovh:me/get. The credentials were perfect and the Test button said they were not, which is the worst thing a test can do: it sends somebody off to regenerate a secret that was never wrong. With telephony coordinates configured the probe now reads the service's call queues instead. That is worth more than /me ever was: one call proves the credentials, the IAM policy, the billing account, the service AND the queue. With no coordinates it still reads the account, which is the right probe for an instance used to look at servers. A service found holding no queue is reported as a SUCCESS with a sentence saying so. It is not a credential failure, and confusing the two is the difference between "go and create a queue" and an evening spent on authentication. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
id: ovhcloud
|
id: ovhcloud
|
||||||
name: OVHcloud
|
name: OVHcloud
|
||||||
version: 1.2.0
|
version: 1.2.1
|
||||||
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.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.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
|
||||||
|
|||||||
@@ -150,9 +150,80 @@ def _run(fn):
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ── the call queue ──────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# THE SAME COORDINATES FOR EVERY ON-CALL COMMAND, hence this block: the billing
|
||||||
|
# account, the service (the number the queue hangs from) and the queue itself.
|
||||||
|
# Repeated in each script rather than imported, because the marketplace collects
|
||||||
|
# loose *.py files and every command has to stand on its own.
|
||||||
|
def _telephony(cfg):
|
||||||
|
ba = str(cfg.get("telephony_billing_account") or "").strip()
|
||||||
|
service = str(cfg.get("telephony_service") or "").strip()
|
||||||
|
if not ba or not service:
|
||||||
|
raise Exception(
|
||||||
|
"this instance has no telephony coordinates: set telephony_billing_account "
|
||||||
|
"(e.g. fr-xxxxx-1) and telephony_service (the call queue's number, 0033xxxxxxxxx)")
|
||||||
|
return ba, service
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
ba, service = _telephony(cfg)
|
||||||
|
return "/telephony/" + urllib.parse.quote(ba, safe="") + "/easyHunting/" + urllib.parse.quote(service, safe="")
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_id(cfg):
|
||||||
|
"""The queue to act on.
|
||||||
|
|
||||||
|
Configured explicitly, or discovered when the service holds exactly one, which is
|
||||||
|
the normal shape. AMBIGUITY IS REFUSED RATHER THAN GUESSED: picking the first of
|
||||||
|
several would publish the on-call ladder into whichever queue OVH happened to
|
||||||
|
list first, and the symptom is a number that rings the wrong team at 03:00.
|
||||||
|
"""
|
||||||
|
configured = str(cfg.get("telephony_queue_id") or "").strip()
|
||||||
|
if configured:
|
||||||
|
return configured
|
||||||
|
ids = request("GET", _base(cfg) + "/hunting/queue", cfg)
|
||||||
|
if not isinstance(ids, list) or not ids:
|
||||||
|
raise Exception("this service holds no call queue; create one in the control panel first")
|
||||||
|
if len(ids) > 1:
|
||||||
|
raise Exception("this service holds %d queues (%s) - set telephony_queue_id to say which one" %
|
||||||
|
(len(ids), ", ".join(str(i) for i in ids)))
|
||||||
|
return str(ids[0])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ── the probe ───────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# IT TESTS WHAT THIS INSTANCE IS FOR, which is not always the same thing.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# 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):
|
def main(cfg, inputs):
|
||||||
|
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:
|
||||||
|
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, "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,
|
||||||
|
"queues": len(queues), "queue_ids": [str(q) for q in queues]}
|
||||||
resp = request("GET", "/me", cfg)
|
resp = request("GET", "/me", cfg)
|
||||||
return {"ok": True, "nichandle": resp.get("nichandle")}
|
return {"ok": True, "auth": scheme, "nichandle": resp.get("nichandle")}
|
||||||
|
|
||||||
|
|
||||||
_run(main)
|
_run(main)
|
||||||
|
|||||||
Reference in New Issue
Block a user