Files
riposte-marketplace/integrations/ovhcloud/scripts/set_oncall_ladder.py
T
f3nris 6e03b17afb feat(ovhcloud): accept a service account (OAuth2) as well as application keys (1.2.0)
The control panel does not offer application keys on every account — some can only
create a SERVICE ACCOUNT, which authenticates with OAuth2 client credentials. An
instance built that way could not talk to the API at all, which is a hard stop for
anybody whose account is in that shape.

Both schemes are now supported, and which one is used is decided by WHAT WAS
FILLED IN rather than 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 every existing instance keeps working untouched.

The API base is corrected to /v1 for a service account. The same routes answer
under /1.0 and /v1, but a Bearer token sent to the legacy spelling fails in a way
that reads as "bad credentials" — which sends somebody off to regenerate a
perfectly good secret. An operator should not have to know that the spelling and
the credential go together.

Nothing in config_schema is required any more. The schema cannot express "one set
or the other", and keeping the historical three required would refuse every
service-account instance; the scripts refuse an instance with neither and name
both sets, which is an error somebody can act on — where a form that will not
submit is one they can only fight.

Two things the shared block now caches for the life of the process, both of which
matter on the ladder path that runs every time the rota turns: the access token
(a token request in front of each of a dozen calls would triple the traffic) and
the clock offset against OVH's time (it was a round trip per signed request).

Verified offline for both schemes: the base URL each one talks to, the headers
that land on the wire, the single token request, and the refusal when neither set
is present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj
2026-09-19 18:31:31 +00:00

355 lines
15 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, corrected to match the credentials in use.
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.
"""
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"
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
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])
def _ovh_number(raw):
"""OVH writes external numbers in 00-prefixed international form.
A "+" is accepted nowhere in a phoneNumber field, so a ladder handed straight from
Riposte - which speaks E.164 - would be refused number by number, and the on-call
ladder would silently stay as it was.
"""
s = "".join(ch for ch in str(raw) if ch.isdigit() or ch == "+").strip()
if s.startswith("+"):
return "00" + s[1:]
return s
def _e164(raw):
"""The inverse, so a readback can be compared with what Riposte published."""
s = "".join(ch for ch in str(raw) if ch.isdigit() or ch == "+").strip()
if s.startswith("+"):
return s
if s.startswith("00"):
return "+" + s[2:]
return s
def _agents(cfg):
"""Every agent of the service, as {agentId: agent}."""
out = {}
for agent_id in request("GET", _base(cfg) + "/hunting/agent", cfg) or []:
out[str(agent_id)] = request("GET", _base(cfg) + "/hunting/agent/" + str(agent_id), cfg)
return out
# ── set_oncall_ladder ───────────────────────────────────────────────────────
#
# Publishes the ordered on-call numbers into the call queue: the agents are the
# rungs, their position is the order, and the queue rings them one after another.
#
# RECONCILES, NEVER REBUILDS. Agents are created when a number has none, updated
# when it has one, and merely LOGGED OUT when somebody drops off the ladder. The
# tempting version - delete every agent, recreate the ladder - throws away whatever
# else the operator set on those agents, and does it every time the rota turns.
#
# IDEMPOTENT, because Riposte republishes the same ladder on a schedule to heal an
# edit made in the carrier's own console.
STRATEGY = "sequentiallyByAgentOrder"
def _apply_strategy(cfg, base, queue_id):
"""Make the queue ring its agents IN ORDER.
Enforced rather than reported, because the order is the entire content of what
we publish: a queue left on ringAll or random takes the same list and rings
everybody at once, which is not the escalation an operator arranged and is
invisible from Riposte. Choosing this instance as the on-call router IS the
instruction to ring sequentially.
"""
queue = request("GET", base + "/hunting/queue/" + str(queue_id), cfg)
if queue.get("strategy") == STRATEGY:
return False
request("PUT", base + "/hunting/queue/" + str(queue_id), cfg, {"strategy": STRATEGY})
return True
def _place(cfg, base, queue_id, agent_id, position):
"""Put an agent at a position in the queue, joining it if it is not a member."""
path = base + "/hunting/agent/" + str(agent_id) + "/queue/" + str(queue_id)
try:
request("GET", path, cfg)
except urllib.error.HTTPError as e:
if e.code != 404:
raise
request("POST", base + "/hunting/agent/" + str(agent_id) + "/queue", cfg,
{"queueId": int(queue_id), "position": position})
return
request("PUT", path, cfg, {"position": position})
def main(cfg, inputs):
numbers = [n for n in (inputs.get("numbers") or []) if str(n).strip()]
if not numbers:
# An empty ladder would log every agent out and send every client to a dead
# line. Riposte never sends one; refusing here means a bug upstream cannot
# take the on-call number down.
raise Exception("refusing to publish an empty ladder: nobody would answer the on-call number")
ring = int(inputs.get("ring_seconds") or 15)
if ring < 5 or ring > 30:
# Bounded for the reason the whole mode exists to work around: a carrier's
# queue cannot ask for a keypress, so a rung ringing past a mobile's voicemail
# pickup (about 20 s) ends the search on an answering machine.
ring = 15
base = _base(cfg)
queue_id = _queue_id(cfg)
strategy_changed = _apply_strategy(cfg, base, queue_id)
wanted = [_ovh_number(n) for n in numbers]
existing = _agents(cfg)
by_number = {}
for agent_id, agent in existing.items():
by_number.setdefault(_ovh_number(agent.get("number")), agent_id)
created, published = 0, []
for position, number in enumerate(wanted, start=1):
agent_id = by_number.get(number)
if agent_id is None:
agent = request("POST", base + "/hunting/agent", cfg, {
"number": number,
"status": "available",
"timeout": ring,
# One call at a time: a rung already talking to a client must not be
# handed the next one, it must be stepped over.
"simultaneousLines": 1,
"wrapUpTime": 0,
"description": "Riposte on-call",
})
agent_id = str(agent.get("agentId"))
by_number[number] = agent_id
created += 1
else:
request("PUT", base + "/hunting/agent/" + str(agent_id), cfg,
{"status": "available", "timeout": ring})
_place(cfg, base, queue_id, agent_id, position)
published.append(_e164(number))
# Everybody else steps off the ladder. LOGGED OUT, NOT DELETED: deleting loses
# whatever the operator configured on that agent, and the person is on the rota
# again next week.
#
# They also leave the QUEUE, which is a different thing from being logged out and
# is what keeps positions unique. A departing agent that kept its position leaves
# two rungs numbered 1 — harmless while it is logged out, and an order nobody can
# read in the control panel, which is where somebody will go to check this at
# 03:00.
logged_out = []
for agent_id, agent in existing.items():
number = _ovh_number(agent.get("number"))
if number in wanted:
continue
if agent.get("status") == "loggedOut":
continue
request("PUT", base + "/hunting/agent/" + str(agent_id), cfg, {"status": "loggedOut"})
try:
request("DELETE", base + "/hunting/agent/" + str(agent_id) + "/queue/" + str(queue_id), cfg)
except urllib.error.HTTPError as e:
# Already out of the queue. Not a failure: the agent is logged out either
# way, which is the part that decides whether their phone rings.
if e.code != 404:
raise
logged_out.append(_e164(number))
return {
"ok": True,
"queue_id": queue_id,
"ladder": published,
"created": created,
"logged_out": logged_out,
"ring_seconds": ring,
"strategy_changed": strategy_changed,
}
_run(main)