7f7868f7cf
---ci--- phase: 5 milestone: v0.1 status: complete requirements: covered: [REQ-01, REQ-02, REQ-03, REQ-04, REQ-05, REQ-06, REQ-07, REQ-08, REQ-09, REQ-10, REQ-11, REQ-12, REQ-13, REQ-14, REQ-15, REQ-16, REQ-17, REQ-18, REQ-19, REQ-20] partial: [] ---/ci---
161 lines
6.0 KiB
Python
161 lines
6.0 KiB
Python
"""Public stdlib-only client for the CoreCI Broker: idempotent tool registration, agent-state persistence, and path-scoped secret reads."""
|
|
import json
|
|
import time
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
REGISTER_PATH = "/api/broker/agents/register"
|
|
SECRETS_PREFIX = "/api/broker/secrets/"
|
|
CLIENT_MODULE_PATH = "/static/coreci_broker_client.py"
|
|
|
|
|
|
class BrokerError(Exception):
|
|
def __init__(self, status, message):
|
|
super().__init__(message)
|
|
self.status = status
|
|
self.message = message
|
|
|
|
|
|
def _broker_error(status, body):
|
|
message = "broker returned HTTP %s" % status
|
|
if isinstance(body, dict) and body.get("message"):
|
|
message = str(body["message"])
|
|
return BrokerError(status, message)
|
|
|
|
|
|
def _epoch_from_iso(value):
|
|
try:
|
|
year, month, day = int(value[0:4]), int(value[5:7]), int(value[8:10])
|
|
hour, minute, second = int(value[11:13]), int(value[14:16]), int(value[17:19])
|
|
rest = value[19:]
|
|
micro = 0
|
|
if rest[:1] == ".":
|
|
digits = ""
|
|
for char in rest[1:]:
|
|
if not char.isdigit():
|
|
break
|
|
digits += char
|
|
micro = int((digits + "000000")[:6])
|
|
rest = rest[1 + len(digits):]
|
|
offset = 0
|
|
if rest[:1] in ("+", "-"):
|
|
sign = 1 if rest[0] == "+" else -1
|
|
offset = sign * (int(rest[1:3]) * 3600 + int(rest[4:6]) * 60)
|
|
if not 1 <= month <= 12 or not 1 <= day <= 31:
|
|
return None
|
|
if hour > 23 or minute > 59 or second > 59:
|
|
return None
|
|
year -= month <= 2
|
|
era = (year if year >= 0 else year - 399) // 400
|
|
yoe = year - era * 400
|
|
doy = (153 * (month - 3 if month > 2 else month + 9) + 2) // 5 + day - 1
|
|
doe = yoe * 365 + yoe // 4 - yoe // 100 + doy
|
|
return (era * 146097 + doe - 719468) * 86400 + hour * 3600 + minute * 60 + second - offset + micro / 1000000.0
|
|
except (ValueError, TypeError, IndexError):
|
|
return None
|
|
|
|
|
|
def _token_live(state):
|
|
token = state.get("current_token")
|
|
expires = _epoch_from_iso(state.get("token_expires_at"))
|
|
return isinstance(token, str) and bool(token) and expires is not None and expires > time.time()
|
|
|
|
|
|
class BrokerClient:
|
|
def __init__(self, base_url, agent_id, app_uuid, user_id, tags=None, timeout=10.0):
|
|
self.base_url = str(base_url).rstrip("/")
|
|
self.agent_id = agent_id
|
|
self.app_uuid = app_uuid
|
|
self.user_id = user_id
|
|
self.tags = list(tags or [])
|
|
self.timeout = timeout
|
|
|
|
def _app_headers(self, state=None):
|
|
token = (state or {}).get("current_token")
|
|
if not isinstance(token, str) or not token:
|
|
return {}
|
|
return {"Authorization": "App %s %s" % (self.app_uuid, token)}
|
|
|
|
def _request(self, method, path, payload=None, headers=None):
|
|
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
|
request = urllib.request.Request(self.base_url + path, data=data, method=method)
|
|
if data is not None:
|
|
request.add_header("Content-Type", "application/json")
|
|
for key, value in (headers or {}).items():
|
|
request.add_header(key, value)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
status, raw = response.status, response.read()
|
|
except urllib.request.HTTPError as error:
|
|
status, raw = error.code, error.read()
|
|
body = None
|
|
text = raw.decode("utf-8", "replace")
|
|
if text:
|
|
try:
|
|
body = json.loads(text)
|
|
except ValueError:
|
|
body = None
|
|
return status, body
|
|
|
|
def _authorized_request(self, method, path, state, payload=None):
|
|
status, body = self._request(method, path, payload, self._app_headers(state))
|
|
if status == 401:
|
|
self.register(state)
|
|
status, body = self._request(method, path, payload, self._app_headers(state))
|
|
return status, body
|
|
|
|
def _state_path(self):
|
|
return "/api/broker/agents/%s/state" % urllib.parse.quote(self.agent_id, safe="")
|
|
|
|
def register(self, state):
|
|
if state is None:
|
|
state = {}
|
|
payload = {"agent_id": self.agent_id, "app_registration_uuid": self.app_uuid,
|
|
"user_id": self.user_id, "tags": list(self.tags)}
|
|
status, data = self._request("POST", REGISTER_PATH, payload, self._app_headers(state))
|
|
if status not in (200, 201) or not isinstance(data, dict):
|
|
raise _broker_error(status, data)
|
|
state["agent_id"] = self.agent_id
|
|
state["app_registration_uuid"] = self.app_uuid
|
|
state["user_id"] = self.user_id
|
|
state["tags"] = list(self.tags)
|
|
state["current_token"] = data["token"]
|
|
state["token_expires_at"] = data["token_expires_at"]
|
|
pepper = data.get("pepper")
|
|
if pepper is not None:
|
|
state["pepper"] = pepper
|
|
return state
|
|
|
|
def ensure_registered(self, state):
|
|
if state is None:
|
|
state = {}
|
|
if _token_live(state):
|
|
return state
|
|
return self.register(state)
|
|
|
|
def get_state(self, state):
|
|
status, body = self._authorized_request("GET", self._state_path(), state)
|
|
if status == 200:
|
|
return body
|
|
if status == 404:
|
|
return None
|
|
raise _broker_error(status, body)
|
|
|
|
def put_state(self, state):
|
|
status, body = self._authorized_request("PUT", self._state_path(), state, state)
|
|
if status == 200:
|
|
return True
|
|
raise _broker_error(status, body)
|
|
|
|
def read_secret(self, path, state):
|
|
suffix = urllib.parse.quote(str(path), safe="/")
|
|
status, body = self._authorized_request("GET", SECRETS_PREFIX + suffix, state)
|
|
if status == 200:
|
|
return body
|
|
raise _broker_error(status, body)
|
|
|
|
|
|
def fetch_client_module(base_url, timeout=10.0):
|
|
url = str(base_url).rstrip("/") + CLIENT_MODULE_PATH
|
|
with urllib.request.urlopen(url, timeout=timeout) as response:
|
|
return response.read().decode("utf-8") |