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
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import json, os, sys, hashlib, urllib.parse, urllib.request, urllib.error
|
||||
import json, os, sys, hashlib, time, urllib.parse, urllib.request, urllib.error
|
||||
|
||||
|
||||
def _cfg():
|
||||
@@ -9,32 +9,126 @@ 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):
|
||||
return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/")
|
||||
"""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 _server_time(cfg):
|
||||
req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET")
|
||||
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:
|
||||
return r.read().decode("utf-8", "replace").strip()
|
||||
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
|
||||
app_key = str(cfg.get("application_key", ""))
|
||||
app_secret = str(cfg.get("application_secret", ""))
|
||||
consumer = str(cfg.get("consumer_key", ""))
|
||||
ts = _server_time(cfg)
|
||||
body_str = json.dumps(body) if body is not None else ""
|
||||
to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts
|
||||
signature = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest()
|
||||
headers = {
|
||||
"X-Ovh-Application": app_key,
|
||||
"X-Ovh-Consumer": consumer,
|
||||
"X-Ovh-Timestamp": ts,
|
||||
"X-Ovh-Signature": signature,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
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")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import json, os, sys, hashlib, urllib.parse, urllib.request, urllib.error
|
||||
import json, os, sys, hashlib, time, urllib.parse, urllib.request, urllib.error
|
||||
|
||||
|
||||
def _cfg():
|
||||
@@ -9,32 +9,126 @@ 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):
|
||||
return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/")
|
||||
"""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 _server_time(cfg):
|
||||
req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET")
|
||||
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:
|
||||
return r.read().decode("utf-8", "replace").strip()
|
||||
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
|
||||
app_key = str(cfg.get("application_key", ""))
|
||||
app_secret = str(cfg.get("application_secret", ""))
|
||||
consumer = str(cfg.get("consumer_key", ""))
|
||||
ts = _server_time(cfg)
|
||||
body_str = json.dumps(body) if body is not None else ""
|
||||
to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts
|
||||
signature = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest()
|
||||
headers = {
|
||||
"X-Ovh-Application": app_key,
|
||||
"X-Ovh-Consumer": consumer,
|
||||
"X-Ovh-Timestamp": ts,
|
||||
"X-Ovh-Signature": signature,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
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")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import json, os, sys, hashlib, urllib.parse, urllib.request, urllib.error
|
||||
import json, os, sys, hashlib, time, urllib.parse, urllib.request, urllib.error
|
||||
|
||||
|
||||
def _cfg():
|
||||
@@ -9,32 +9,126 @@ 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):
|
||||
return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/")
|
||||
"""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 _server_time(cfg):
|
||||
req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET")
|
||||
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:
|
||||
return r.read().decode("utf-8", "replace").strip()
|
||||
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
|
||||
app_key = str(cfg.get("application_key", ""))
|
||||
app_secret = str(cfg.get("application_secret", ""))
|
||||
consumer = str(cfg.get("consumer_key", ""))
|
||||
ts = _server_time(cfg)
|
||||
body_str = json.dumps(body) if body is not None else ""
|
||||
to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts
|
||||
signature = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest()
|
||||
headers = {
|
||||
"X-Ovh-Application": app_key,
|
||||
"X-Ovh-Consumer": consumer,
|
||||
"X-Ovh-Timestamp": ts,
|
||||
"X-Ovh-Signature": signature,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
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")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import json, os, sys, hashlib, urllib.parse, urllib.request, urllib.error
|
||||
import json, os, sys, hashlib, time, urllib.parse, urllib.request, urllib.error
|
||||
|
||||
|
||||
def _cfg():
|
||||
@@ -9,32 +9,126 @@ 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):
|
||||
return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/")
|
||||
"""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 _server_time(cfg):
|
||||
req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET")
|
||||
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:
|
||||
return r.read().decode("utf-8", "replace").strip()
|
||||
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
|
||||
app_key = str(cfg.get("application_key", ""))
|
||||
app_secret = str(cfg.get("application_secret", ""))
|
||||
consumer = str(cfg.get("consumer_key", ""))
|
||||
ts = _server_time(cfg)
|
||||
body_str = json.dumps(body) if body is not None else ""
|
||||
to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts
|
||||
signature = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest()
|
||||
headers = {
|
||||
"X-Ovh-Application": app_key,
|
||||
"X-Ovh-Consumer": consumer,
|
||||
"X-Ovh-Timestamp": ts,
|
||||
"X-Ovh-Signature": signature,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
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")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import json, os, sys, hashlib, urllib.parse, urllib.request, urllib.error
|
||||
import json, os, sys, hashlib, time, urllib.parse, urllib.request, urllib.error
|
||||
|
||||
|
||||
def _cfg():
|
||||
@@ -9,32 +9,126 @@ 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):
|
||||
return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/")
|
||||
"""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 _server_time(cfg):
|
||||
req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET")
|
||||
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:
|
||||
return r.read().decode("utf-8", "replace").strip()
|
||||
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
|
||||
app_key = str(cfg.get("application_key", ""))
|
||||
app_secret = str(cfg.get("application_secret", ""))
|
||||
consumer = str(cfg.get("consumer_key", ""))
|
||||
ts = _server_time(cfg)
|
||||
body_str = json.dumps(body) if body is not None else ""
|
||||
to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts
|
||||
signature = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest()
|
||||
headers = {
|
||||
"X-Ovh-Application": app_key,
|
||||
"X-Ovh-Consumer": consumer,
|
||||
"X-Ovh-Timestamp": ts,
|
||||
"X-Ovh-Signature": signature,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
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")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import json, os, sys, hashlib, urllib.parse, urllib.request, urllib.error
|
||||
import json, os, sys, hashlib, time, urllib.parse, urllib.request, urllib.error
|
||||
|
||||
|
||||
def _cfg():
|
||||
@@ -9,32 +9,126 @@ 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):
|
||||
return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/")
|
||||
"""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 _server_time(cfg):
|
||||
req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET")
|
||||
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:
|
||||
return r.read().decode("utf-8", "replace").strip()
|
||||
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
|
||||
app_key = str(cfg.get("application_key", ""))
|
||||
app_secret = str(cfg.get("application_secret", ""))
|
||||
consumer = str(cfg.get("consumer_key", ""))
|
||||
ts = _server_time(cfg)
|
||||
body_str = json.dumps(body) if body is not None else ""
|
||||
to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts
|
||||
signature = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest()
|
||||
headers = {
|
||||
"X-Ovh-Application": app_key,
|
||||
"X-Ovh-Consumer": consumer,
|
||||
"X-Ovh-Timestamp": ts,
|
||||
"X-Ovh-Signature": signature,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
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")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import json, os, sys, hashlib, urllib.parse, urllib.request, urllib.error
|
||||
import json, os, sys, hashlib, time, urllib.parse, urllib.request, urllib.error
|
||||
|
||||
|
||||
def _cfg():
|
||||
@@ -9,32 +9,126 @@ 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):
|
||||
return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/")
|
||||
"""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 _server_time(cfg):
|
||||
req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET")
|
||||
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:
|
||||
return r.read().decode("utf-8", "replace").strip()
|
||||
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
|
||||
app_key = str(cfg.get("application_key", ""))
|
||||
app_secret = str(cfg.get("application_secret", ""))
|
||||
consumer = str(cfg.get("consumer_key", ""))
|
||||
ts = _server_time(cfg)
|
||||
body_str = json.dumps(body) if body is not None else ""
|
||||
to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts
|
||||
signature = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest()
|
||||
headers = {
|
||||
"X-Ovh-Application": app_key,
|
||||
"X-Ovh-Consumer": consumer,
|
||||
"X-Ovh-Timestamp": ts,
|
||||
"X-Ovh-Signature": signature,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
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")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import json, os, sys, hashlib, urllib.parse, urllib.request, urllib.error
|
||||
import json, os, sys, hashlib, time, urllib.parse, urllib.request, urllib.error
|
||||
|
||||
|
||||
def _cfg():
|
||||
@@ -9,32 +9,126 @@ 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):
|
||||
return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/")
|
||||
"""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 _server_time(cfg):
|
||||
req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET")
|
||||
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:
|
||||
return r.read().decode("utf-8", "replace").strip()
|
||||
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
|
||||
app_key = str(cfg.get("application_key", ""))
|
||||
app_secret = str(cfg.get("application_secret", ""))
|
||||
consumer = str(cfg.get("consumer_key", ""))
|
||||
ts = _server_time(cfg)
|
||||
body_str = json.dumps(body) if body is not None else ""
|
||||
to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts
|
||||
signature = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest()
|
||||
headers = {
|
||||
"X-Ovh-Application": app_key,
|
||||
"X-Ovh-Consumer": consumer,
|
||||
"X-Ovh-Timestamp": ts,
|
||||
"X-Ovh-Signature": signature,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user