ed56523b07
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj
366 lines
17 KiB
Python
366 lines
17 KiB
Python
import json, os, sys, hashlib, time, urllib.parse, urllib.request, urllib.error
|
|
|
|
|
|
def _cfg():
|
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
|
|
|
|
|
def _inputs():
|
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
|
|
|
|
|
# ── authentication ──────────────────────────────────────────────────────────
|
|
#
|
|
# TWO SCHEMES, because OVHcloud now offers two and an operator does not always get
|
|
# to choose. The historical one signs each request with an application key/secret
|
|
# plus a consumer key. The newer SERVICE ACCOUNT is OAuth2 client-credentials, and
|
|
# it is what the control panel offers when application keys are not available on
|
|
# the account.
|
|
#
|
|
# Which one is in use is decided by what was filled in, not by a mode selector:
|
|
# a client_id and a client_secret mean the service account, anything else falls
|
|
# back to the signed scheme. One less field to get wrong, and an instance created
|
|
# before service accounts existed keeps working untouched.
|
|
_TOKEN_HOSTS = {
|
|
"eu": "https://www.ovh.com",
|
|
"ca": "https://ca.ovh.com",
|
|
"us": "https://us.ovhcloud.com",
|
|
}
|
|
_CACHE = {}
|
|
|
|
|
|
def _oauth(cfg):
|
|
return bool(str(cfg.get("client_id") or "").strip() and str(cfg.get("client_secret") or "").strip())
|
|
|
|
|
|
def _endpoint(cfg):
|
|
"""The API base, exactly as configured.
|
|
|
|
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.
|
|
"""
|
|
return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/")
|
|
|
|
|
|
def _token_url(cfg):
|
|
explicit = str(cfg.get("oauth_token_url") or "").strip()
|
|
if explicit:
|
|
return explicit
|
|
host = urllib.parse.urlparse(_endpoint(cfg)).netloc
|
|
region = host.split(".")[0] if host else "eu"
|
|
return _TOKEN_HOSTS.get(region, _TOKEN_HOSTS["eu"]) + "/auth/oauth2/token"
|
|
|
|
|
|
def _bearer(cfg):
|
|
"""The service account's access token.
|
|
|
|
CACHED FOR THE LIFE OF THE PROCESS, which is not a micro-optimisation: publishing
|
|
an on-call ladder makes a dozen or more API calls, and a token request in front
|
|
of each one would triple the traffic and the time — on the path that runs every
|
|
time the rota turns.
|
|
"""
|
|
if _CACHE.get("token"):
|
|
return _CACHE["token"]
|
|
data = urllib.parse.urlencode({
|
|
"grant_type": "client_credentials",
|
|
"client_id": str(cfg.get("client_id", "")).strip(),
|
|
"client_secret": str(cfg.get("client_secret", "")).strip(),
|
|
# The scope the service account was granted in the control panel is what
|
|
# actually bounds it; "all" here asks for everything that account may do.
|
|
# Narrow the SERVICE ACCOUNT's own IAM policy rather than this value.
|
|
"scope": "all",
|
|
}).encode("utf-8")
|
|
req = urllib.request.Request(
|
|
_token_url(cfg), data=data, method="POST",
|
|
headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"})
|
|
with urllib.request.urlopen(req, timeout=30) as r:
|
|
payload = json.loads(r.read() or b"{}")
|
|
token = payload.get("access_token")
|
|
if not token:
|
|
raise Exception("the service account returned no access token: " + json.dumps(payload)[:200])
|
|
_CACHE["token"] = token
|
|
return token
|
|
|
|
|
|
def _time_offset(cfg):
|
|
"""Seconds between this clock and OVH's, read once.
|
|
|
|
The signature carries a timestamp and OVH refuses one that has drifted, so the
|
|
server's own time is the safe source. Read ONCE and applied as an offset rather
|
|
than fetched per request: the ladder makes many calls, and an extra round trip
|
|
in front of each was doubling them.
|
|
"""
|
|
if "offset" not in _CACHE:
|
|
req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET")
|
|
with urllib.request.urlopen(req, timeout=30) as r:
|
|
server = int(r.read().decode("utf-8", "replace").strip())
|
|
_CACHE["offset"] = server - int(time.time())
|
|
return _CACHE["offset"]
|
|
|
|
|
|
def request(method, path, cfg, body=None):
|
|
url = _endpoint(cfg) + path
|
|
body_str = json.dumps(body) if body is not None else ""
|
|
headers = {"Accept": "application/json"}
|
|
if _oauth(cfg):
|
|
headers["Authorization"] = "Bearer " + _bearer(cfg)
|
|
else:
|
|
app_key = str(cfg.get("application_key", "")).strip()
|
|
app_secret = str(cfg.get("application_secret", "")).strip()
|
|
consumer = str(cfg.get("consumer_key", "")).strip()
|
|
if not (app_key and app_secret and consumer):
|
|
raise Exception(
|
|
"this instance has no credentials: fill in either a service account "
|
|
"(client_id + client_secret) or the application key, secret and consumer key")
|
|
ts = str(int(time.time()) + _time_offset(cfg))
|
|
to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts
|
|
headers["X-Ovh-Application"] = app_key
|
|
headers["X-Ovh-Consumer"] = consumer
|
|
headers["X-Ovh-Timestamp"] = ts
|
|
headers["X-Ovh-Signature"] = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest()
|
|
data = None
|
|
if body is not None:
|
|
data = body_str.encode("utf-8")
|
|
headers["Content-Type"] = "application/json"
|
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
with urllib.request.urlopen(req, timeout=60) as r:
|
|
raw = r.read()
|
|
return json.loads(raw) if raw else {}
|
|
|
|
|
|
def _run(fn):
|
|
try:
|
|
print(json.dumps(fn(_cfg(), _inputs())))
|
|
except urllib.error.HTTPError as e:
|
|
print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")}))
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
print(json.dumps({"error": str(e)}))
|
|
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
|
|
|
|
|
|
# The call queue lives under ONE OF TWO PRODUCTS, and which one is not something an
|
|
# operator can see: the control panel says "File d'appels" for both. OVH files them
|
|
# under easyHunting or under ovhPabx depending on the offer the number carries, and
|
|
# the hunting routes underneath are identical either way.
|
|
#
|
|
# Assuming easyHunting is what this did first, and the symptom was a 404 reading
|
|
# "This service does not exist" — which sounds like a wrong number and is in fact a
|
|
# wrong product. So it is DISCOVERED, once per process, and a failure says what does
|
|
# exist rather than what does not.
|
|
_PRODUCTS = ("easyHunting", "ovhPabx")
|
|
|
|
|
|
def _product(cfg):
|
|
if _CACHE.get("product"):
|
|
return _CACHE["product"]
|
|
ba, service = _telephony(cfg)
|
|
found = {}
|
|
for product in _PRODUCTS:
|
|
names = request("GET", "/telephony/" + urllib.parse.quote(ba, safe="") + "/" + product, cfg) or []
|
|
found[product] = [str(n) for n in names]
|
|
if service in found[product]:
|
|
_CACHE["product"] = product
|
|
return product
|
|
raise Exception(
|
|
"no call queue named %s on %s. easyHunting holds [%s]; ovhPabx holds [%s]. "
|
|
"Set telephony_service to one of those names, or configure the number as a "
|
|
"call queue in the control panel first."
|
|
% (service, ba, ", ".join(found.get("easyHunting") or ["-"]), ", ".join(found.get("ovhPabx") or ["-"])))
|
|
|
|
|
|
def _base(cfg):
|
|
ba, service = _telephony(cfg)
|
|
return ("/telephony/" + urllib.parse.quote(ba, safe="") + "/" + _product(cfg) +
|
|
"/" + 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, and it walks there ONE STEP AT A TIME.
|
|
#
|
|
# 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 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.6"
|
|
|
|
|
|
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()
|
|
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? Useful when it works, and NEVER
|
|
# FATAL when it does not.
|
|
#
|
|
# It is a diagnostic, not a dependency: nothing in this integration needs the
|
|
# list of billing accounts, and on a service account this route has been seen
|
|
# to answer 500 with an empty body while every account-scoped route underneath
|
|
# it works perfectly. A diagnostic step that fails the test it exists to
|
|
# explain is worse than no step at all — it hides the answer behind its own
|
|
# 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 + ")"
|
|
else:
|
|
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
|
|
# 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:
|
|
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)]
|
|
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.
|
|
detail = ("neither product could be listed on %s. A 403 means the IAM policy is missing the "
|
|
"telephony actions; a 404 means this billing account does not exist under these "
|
|
"credentials; a 500 on every telephony route means this service account cannot reach "
|
|
"the telephony API at all, and the way round is an application key from "
|
|
"https://api.ovh.com/createToken/ (fill in application_key, application_secret and "
|
|
"consumer_key instead). Details: %s") % (ba, " | ".join(failures))
|
|
else:
|
|
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))
|
|
out.update({"ok": False, "step": "service", "detail": detail})
|
|
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. "
|
|
"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)
|