From 42032e576eed895e35c287ddd181644aedb734b1 Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Sat, 19 Sep 2026 19:26:02 +0000 Subject: [PATCH] fix(ovhcloud): use the API endpoint as configured, for both schemes (1.2.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.2.0 rewrote /1.0 to /v1 whenever a service account was in use, reasoning from a documentation example that showed OAuth2 against a /v1 path. It was wrong, and it was the cause of the 404s and the 500 that followed: OVH's own API console issues Bearer requests against /1.0, while /v1 — which exists, and answers 401 unauthenticated like any live route — answers 500 with an empty body for a token on the telephony routes. A failure with no message at all, produced by a helpful rewrite nobody asked for. The endpoint is now whatever was typed, for both authentication schemes. The config field says where to copy it from. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj --- integrations/ovhcloud/manifest.yaml | 6 ++-- integrations/ovhcloud/scripts/get_account.py | 28 ++++++++--------- .../ovhcloud/scripts/get_dedicated_server.py | 28 ++++++++--------- .../ovhcloud/scripts/get_oncall_ladder.py | 28 ++++++++--------- integrations/ovhcloud/scripts/list_calls.py | 28 ++++++++--------- .../ovhcloud/scripts/list_cloud_projects.py | 28 ++++++++--------- .../scripts/list_dedicated_servers.py | 28 ++++++++--------- .../ovhcloud/scripts/set_oncall_ladder.py | 28 ++++++++--------- .../ovhcloud/scripts/test_connection.py | 30 +++++++++---------- 9 files changed, 108 insertions(+), 124 deletions(-) diff --git a/integrations/ovhcloud/manifest.yaml b/integrations/ovhcloud/manifest.yaml index ed89a89..90262e9 100644 --- a/integrations/ovhcloud/manifest.yaml +++ b/integrations/ovhcloud/manifest.yaml @@ -1,8 +1,8 @@ id: ovhcloud name: OVHcloud -version: 1.2.4 +version: 1.2.5 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.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.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 @@ -11,7 +11,7 @@ config_schema: properties: endpoint: type: string - description: "OVH API endpoint" + description: "OVH API endpoint, copied from the API console — used exactly as typed, for both authentication schemes" default: "https://eu.api.ovh.com/1.0" # ── credentials: fill in ONE of the two sets ───────────────────────── # diff --git a/integrations/ovhcloud/scripts/get_account.py b/integrations/ovhcloud/scripts/get_account.py index 871421c..2ad596c 100644 --- a/integrations/ovhcloud/scripts/get_account.py +++ b/integrations/ovhcloud/scripts/get_account.py @@ -34,23 +34,21 @@ def _oauth(cfg): def _endpoint(cfg): - """The API base, corrected to match the credentials in use. + """The API base, exactly as configured. - The same routes answer under /1.0 and under /v1; the first is the signed - scheme's historical spelling and the second is the one OAuth2 documents. An - operator should not have to know that, so a service account gets /v1 whatever - was typed — a Bearer token sent to the legacy spelling is the kind of failure - that reads as "bad credentials" and sends somebody to regenerate a perfectly - good secret. + IT USED TO REWRITE /1.0 TO /v1 FOR A SERVICE ACCOUNT, on the strength of a + documentation example that showed OAuth2 against a /v1 path. That was wrong and + cost two rounds of debugging: OVH's own API console issues Bearer requests + against /1.0, and /v1 — which exists, and answers 401 unauthenticated like any + live route — answers 500 with an empty body for a token on the telephony + routes. A failure with no message, produced by a helpful rewrite nobody asked + for. + + So the endpoint is now whatever was typed, for both schemes. Both spellings + work for the signed scheme; only the configured one is used, and OVH's console + is the place to copy it from. """ - raw = (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") - if not _oauth(cfg): - return raw - if raw.endswith("/1.0"): - return raw[:-4] + "/v1" - if raw.endswith("/v1") or raw.endswith("/v2"): - return raw - return raw + "/v1" + return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") def _token_url(cfg): diff --git a/integrations/ovhcloud/scripts/get_dedicated_server.py b/integrations/ovhcloud/scripts/get_dedicated_server.py index c0c6b9c..9a8e165 100644 --- a/integrations/ovhcloud/scripts/get_dedicated_server.py +++ b/integrations/ovhcloud/scripts/get_dedicated_server.py @@ -34,23 +34,21 @@ def _oauth(cfg): def _endpoint(cfg): - """The API base, corrected to match the credentials in use. + """The API base, exactly as configured. - The same routes answer under /1.0 and under /v1; the first is the signed - scheme's historical spelling and the second is the one OAuth2 documents. An - operator should not have to know that, so a service account gets /v1 whatever - was typed — a Bearer token sent to the legacy spelling is the kind of failure - that reads as "bad credentials" and sends somebody to regenerate a perfectly - good secret. + IT USED TO REWRITE /1.0 TO /v1 FOR A SERVICE ACCOUNT, on the strength of a + documentation example that showed OAuth2 against a /v1 path. That was wrong and + cost two rounds of debugging: OVH's own API console issues Bearer requests + against /1.0, and /v1 — which exists, and answers 401 unauthenticated like any + live route — answers 500 with an empty body for a token on the telephony + routes. A failure with no message, produced by a helpful rewrite nobody asked + for. + + So the endpoint is now whatever was typed, for both schemes. Both spellings + work for the signed scheme; only the configured one is used, and OVH's console + is the place to copy it from. """ - raw = (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") - if not _oauth(cfg): - return raw - if raw.endswith("/1.0"): - return raw[:-4] + "/v1" - if raw.endswith("/v1") or raw.endswith("/v2"): - return raw - return raw + "/v1" + return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") def _token_url(cfg): diff --git a/integrations/ovhcloud/scripts/get_oncall_ladder.py b/integrations/ovhcloud/scripts/get_oncall_ladder.py index 724d816..dbf8b64 100644 --- a/integrations/ovhcloud/scripts/get_oncall_ladder.py +++ b/integrations/ovhcloud/scripts/get_oncall_ladder.py @@ -34,23 +34,21 @@ def _oauth(cfg): def _endpoint(cfg): - """The API base, corrected to match the credentials in use. + """The API base, exactly as configured. - The same routes answer under /1.0 and under /v1; the first is the signed - scheme's historical spelling and the second is the one OAuth2 documents. An - operator should not have to know that, so a service account gets /v1 whatever - was typed — a Bearer token sent to the legacy spelling is the kind of failure - that reads as "bad credentials" and sends somebody to regenerate a perfectly - good secret. + IT USED TO REWRITE /1.0 TO /v1 FOR A SERVICE ACCOUNT, on the strength of a + documentation example that showed OAuth2 against a /v1 path. That was wrong and + cost two rounds of debugging: OVH's own API console issues Bearer requests + against /1.0, and /v1 — which exists, and answers 401 unauthenticated like any + live route — answers 500 with an empty body for a token on the telephony + routes. A failure with no message, produced by a helpful rewrite nobody asked + for. + + So the endpoint is now whatever was typed, for both schemes. Both spellings + work for the signed scheme; only the configured one is used, and OVH's console + is the place to copy it from. """ - raw = (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") - if not _oauth(cfg): - return raw - if raw.endswith("/1.0"): - return raw[:-4] + "/v1" - if raw.endswith("/v1") or raw.endswith("/v2"): - return raw - return raw + "/v1" + return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") def _token_url(cfg): diff --git a/integrations/ovhcloud/scripts/list_calls.py b/integrations/ovhcloud/scripts/list_calls.py index 34d05ab..c8d0561 100644 --- a/integrations/ovhcloud/scripts/list_calls.py +++ b/integrations/ovhcloud/scripts/list_calls.py @@ -34,23 +34,21 @@ def _oauth(cfg): def _endpoint(cfg): - """The API base, corrected to match the credentials in use. + """The API base, exactly as configured. - The same routes answer under /1.0 and under /v1; the first is the signed - scheme's historical spelling and the second is the one OAuth2 documents. An - operator should not have to know that, so a service account gets /v1 whatever - was typed — a Bearer token sent to the legacy spelling is the kind of failure - that reads as "bad credentials" and sends somebody to regenerate a perfectly - good secret. + IT USED TO REWRITE /1.0 TO /v1 FOR A SERVICE ACCOUNT, on the strength of a + documentation example that showed OAuth2 against a /v1 path. That was wrong and + cost two rounds of debugging: OVH's own API console issues Bearer requests + against /1.0, and /v1 — which exists, and answers 401 unauthenticated like any + live route — answers 500 with an empty body for a token on the telephony + routes. A failure with no message, produced by a helpful rewrite nobody asked + for. + + So the endpoint is now whatever was typed, for both schemes. Both spellings + work for the signed scheme; only the configured one is used, and OVH's console + is the place to copy it from. """ - raw = (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") - if not _oauth(cfg): - return raw - if raw.endswith("/1.0"): - return raw[:-4] + "/v1" - if raw.endswith("/v1") or raw.endswith("/v2"): - return raw - return raw + "/v1" + return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") def _token_url(cfg): diff --git a/integrations/ovhcloud/scripts/list_cloud_projects.py b/integrations/ovhcloud/scripts/list_cloud_projects.py index 113b909..2a2ce2d 100644 --- a/integrations/ovhcloud/scripts/list_cloud_projects.py +++ b/integrations/ovhcloud/scripts/list_cloud_projects.py @@ -34,23 +34,21 @@ def _oauth(cfg): def _endpoint(cfg): - """The API base, corrected to match the credentials in use. + """The API base, exactly as configured. - The same routes answer under /1.0 and under /v1; the first is the signed - scheme's historical spelling and the second is the one OAuth2 documents. An - operator should not have to know that, so a service account gets /v1 whatever - was typed — a Bearer token sent to the legacy spelling is the kind of failure - that reads as "bad credentials" and sends somebody to regenerate a perfectly - good secret. + IT USED TO REWRITE /1.0 TO /v1 FOR A SERVICE ACCOUNT, on the strength of a + documentation example that showed OAuth2 against a /v1 path. That was wrong and + cost two rounds of debugging: OVH's own API console issues Bearer requests + against /1.0, and /v1 — which exists, and answers 401 unauthenticated like any + live route — answers 500 with an empty body for a token on the telephony + routes. A failure with no message, produced by a helpful rewrite nobody asked + for. + + So the endpoint is now whatever was typed, for both schemes. Both spellings + work for the signed scheme; only the configured one is used, and OVH's console + is the place to copy it from. """ - raw = (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") - if not _oauth(cfg): - return raw - if raw.endswith("/1.0"): - return raw[:-4] + "/v1" - if raw.endswith("/v1") or raw.endswith("/v2"): - return raw - return raw + "/v1" + return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") def _token_url(cfg): diff --git a/integrations/ovhcloud/scripts/list_dedicated_servers.py b/integrations/ovhcloud/scripts/list_dedicated_servers.py index c5b33ca..cff7345 100644 --- a/integrations/ovhcloud/scripts/list_dedicated_servers.py +++ b/integrations/ovhcloud/scripts/list_dedicated_servers.py @@ -34,23 +34,21 @@ def _oauth(cfg): def _endpoint(cfg): - """The API base, corrected to match the credentials in use. + """The API base, exactly as configured. - The same routes answer under /1.0 and under /v1; the first is the signed - scheme's historical spelling and the second is the one OAuth2 documents. An - operator should not have to know that, so a service account gets /v1 whatever - was typed — a Bearer token sent to the legacy spelling is the kind of failure - that reads as "bad credentials" and sends somebody to regenerate a perfectly - good secret. + IT USED TO REWRITE /1.0 TO /v1 FOR A SERVICE ACCOUNT, on the strength of a + documentation example that showed OAuth2 against a /v1 path. That was wrong and + cost two rounds of debugging: OVH's own API console issues Bearer requests + against /1.0, and /v1 — which exists, and answers 401 unauthenticated like any + live route — answers 500 with an empty body for a token on the telephony + routes. A failure with no message, produced by a helpful rewrite nobody asked + for. + + So the endpoint is now whatever was typed, for both schemes. Both spellings + work for the signed scheme; only the configured one is used, and OVH's console + is the place to copy it from. """ - raw = (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") - if not _oauth(cfg): - return raw - if raw.endswith("/1.0"): - return raw[:-4] + "/v1" - if raw.endswith("/v1") or raw.endswith("/v2"): - return raw - return raw + "/v1" + return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") def _token_url(cfg): diff --git a/integrations/ovhcloud/scripts/set_oncall_ladder.py b/integrations/ovhcloud/scripts/set_oncall_ladder.py index 19c1fa1..f0290f8 100644 --- a/integrations/ovhcloud/scripts/set_oncall_ladder.py +++ b/integrations/ovhcloud/scripts/set_oncall_ladder.py @@ -34,23 +34,21 @@ def _oauth(cfg): def _endpoint(cfg): - """The API base, corrected to match the credentials in use. + """The API base, exactly as configured. - The same routes answer under /1.0 and under /v1; the first is the signed - scheme's historical spelling and the second is the one OAuth2 documents. An - operator should not have to know that, so a service account gets /v1 whatever - was typed — a Bearer token sent to the legacy spelling is the kind of failure - that reads as "bad credentials" and sends somebody to regenerate a perfectly - good secret. + IT USED TO REWRITE /1.0 TO /v1 FOR A SERVICE ACCOUNT, on the strength of a + documentation example that showed OAuth2 against a /v1 path. That was wrong and + cost two rounds of debugging: OVH's own API console issues Bearer requests + against /1.0, and /v1 — which exists, and answers 401 unauthenticated like any + live route — answers 500 with an empty body for a token on the telephony + routes. A failure with no message, produced by a helpful rewrite nobody asked + for. + + So the endpoint is now whatever was typed, for both schemes. Both spellings + work for the signed scheme; only the configured one is used, and OVH's console + is the place to copy it from. """ - raw = (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") - if not _oauth(cfg): - return raw - if raw.endswith("/1.0"): - return raw[:-4] + "/v1" - if raw.endswith("/v1") or raw.endswith("/v2"): - return raw - return raw + "/v1" + return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") def _token_url(cfg): diff --git a/integrations/ovhcloud/scripts/test_connection.py b/integrations/ovhcloud/scripts/test_connection.py index 0b51ba8..5333273 100644 --- a/integrations/ovhcloud/scripts/test_connection.py +++ b/integrations/ovhcloud/scripts/test_connection.py @@ -34,23 +34,21 @@ def _oauth(cfg): def _endpoint(cfg): - """The API base, corrected to match the credentials in use. + """The API base, exactly as configured. - The same routes answer under /1.0 and under /v1; the first is the signed - scheme's historical spelling and the second is the one OAuth2 documents. An - operator should not have to know that, so a service account gets /v1 whatever - was typed — a Bearer token sent to the legacy spelling is the kind of failure - that reads as "bad credentials" and sends somebody to regenerate a perfectly - good secret. + IT USED TO REWRITE /1.0 TO /v1 FOR A SERVICE ACCOUNT, on the strength of a + documentation example that showed OAuth2 against a /v1 path. That was wrong and + cost two rounds of debugging: OVH's own API console issues Bearer requests + against /1.0, and /v1 — which exists, and answers 401 unauthenticated like any + live route — answers 500 with an empty body for a token on the telephony + routes. A failure with no message, produced by a helpful rewrite nobody asked + for. + + So the endpoint is now whatever was typed, for both schemes. Both spellings + work for the signed scheme; only the configured one is used, and OVH's console + is the place to copy it from. """ - raw = (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") - if not _oauth(cfg): - return raw - if raw.endswith("/1.0"): - return raw[:-4] + "/v1" - if raw.endswith("/v1") or raw.endswith("/v2"): - return raw - return raw + "/v1" + return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") def _token_url(cfg): @@ -240,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.4" +VERSION = "1.2.5" def _try(step, fn):