From ed56523b07c17155a21e040fbc83c8af3094091b Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Sat, 19 Sep 2026 19:29:09 +0000 Subject: [PATCH] fix(ovhcloud): treat the billing-account list as advice, never as a verdict (1.2.6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The listing now answers 200 with an empty array rather than erroring, and the probe failed on "not one of the accounts these credentials can see" — the same mistake as 1.2.4, in its other shape: a diagnostic step deciding the outcome of the test it exists to explain. OVH's IAM filters what you may SEE rather than refusing you, so an empty list is how it says no — and a policy can grant a resource without granting the route that enumerates it, which makes "not in the list" weak evidence either way. Only the account-scoped routes decide now. When nothing is reachable anywhere, the message names the IAM policy as the cause and says what to grant, because an empty array is the least self-explanatory refusal this API produces. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj --- integrations/ovhcloud/manifest.yaml | 4 +-- .../ovhcloud/scripts/test_connection.py | 28 ++++++++++++------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/integrations/ovhcloud/manifest.yaml b/integrations/ovhcloud/manifest.yaml index 90262e9..03136fb 100644 --- a/integrations/ovhcloud/manifest.yaml +++ b/integrations/ovhcloud/manifest.yaml @@ -1,8 +1,8 @@ id: ovhcloud name: OVHcloud -version: 1.2.5 +version: 1.2.6 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.5 — Stop rewriting the API path for a service account. 1.2.0 turned /1.0 into /v1 on the strength of a documentation example, and OVH's own console issues Bearer requests against /1.0 — while /v1 answers 500 with an empty body for a token on the telephony routes. The endpoint is now used exactly as configured, for both authentication schemes. | 1.2.4 — The billing-account listing is no longer fatal. On a service account it can answer 500 with an empty body while every account-scoped route underneath works — so the diagnostic step was failing the test it exists to explain, and hiding the answer behind its own failure. When neither product can be listed either, the error now names what each HTTP code means and points at the application-key fallback. | 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." +changelog: "1.2.6 — The billing-account check is advisory in every case, not only when it errors: OVH's IAM answers an EMPTY LIST rather than a refusal, and a policy can grant a resource without granting the route that enumerates it — so \"not in the list\" was failing instances whose account-scoped routes work. When nothing is reachable anywhere the message now names the IAM policy as the cause and says what to grant. | 1.2.5 — Stop rewriting the API path for a service account. 1.2.0 turned /1.0 into /v1 on the strength of a documentation example, and OVH's own console issues Bearer requests against /1.0 — while /v1 answers 500 with an empty body for a token on the telephony routes. The endpoint is now used exactly as configured, for both authentication schemes. | 1.2.4 — The billing-account listing is no longer fatal. On a service account it can answer 500 with an empty body while every account-scoped route underneath works — so the diagnostic step was failing the test it exists to explain, and hiding the answer behind its own failure. When neither product can be listed either, the error now names what each HTTP code means and points at the application-key fallback. | 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 # Per-instance configuration. Requests are signed (SHA-1) with the application diff --git a/integrations/ovhcloud/scripts/test_connection.py b/integrations/ovhcloud/scripts/test_connection.py index 5333273..4902804 100644 --- a/integrations/ovhcloud/scripts/test_connection.py +++ b/integrations/ovhcloud/scripts/test_connection.py @@ -238,7 +238,7 @@ def _queue_id(cfg): # 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.5" +VERSION = "1.2.6" def _try(step, fn): @@ -273,15 +273,9 @@ def _walk(cfg, inputs): # failure, which is exactly what it did here. accounts, err = _try("listing telephony billing accounts", lambda: request("GET", "/telephony", cfg)) if err: - out["billing_accounts"] = "could not be listed (" + err + ") — not fatal, carrying on" + out["billing_accounts"] = "could not be listed (" + err + ")" else: - 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 + out["billing_accounts"] = [str(a) for a in (accounts or [])] # 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 @@ -300,7 +294,21 @@ def _walk(cfg, inputs): if product is None: listed = [n for p in _PRODUCTS for n in (services[p] if isinstance(services[p], list) else [])] failures = [services[p] for p in _PRODUCTS if not isinstance(services[p], list)] - if not listed and failures: + seen = out.get("billing_accounts") + if isinstance(seen, list) and ba not in seen and not listed: + # The IAM policy is the likeliest answer, and an empty LIST is how OVH's + # IAM says no: it filters what you may see rather than refusing you. Named + # here rather than at the listing step, because an empty list on its own + # is not proof — a policy can grant a resource without granting the route + # that enumerates it, which is why that check is advisory and this one is + # not. + detail = ("these credentials can see no telephony billing account at all, and %s is not " + "reachable. Grant this service account the telephony resources in the control " + "panel's Identity and Access Management: a policy on the account %s (or on the " + "telephony resource group) with the telephony actions. OVH's IAM answers an empty " + "list rather than a refusal, which is why this reads as \"none\" rather than as a 403. " + "Details: %s") % (ba, ba, " | ".join(failures) or "both product listings came back empty") + elif not listed and failures: # Neither product could even be LISTED. That is not a wrong service name, # it is the account or the credentials — and the three shapes it comes in # want three different answers, so they are named rather than merged.