keepup-gateway 0.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- keepup_gateway-0.6.0/PKG-INFO +29 -0
- keepup_gateway-0.6.0/README.md +16 -0
- keepup_gateway-0.6.0/keepup_gateway/__init__.py +28 -0
- keepup_gateway-0.6.0/keepup_gateway/announcer.py +142 -0
- keepup_gateway-0.6.0/keepup_gateway/applications.py +192 -0
- keepup_gateway-0.6.0/keepup_gateway/balancer.py +73 -0
- keepup_gateway-0.6.0/keepup_gateway/headers.py +104 -0
- keepup_gateway-0.6.0/keepup_gateway/identity.py +169 -0
- keepup_gateway-0.6.0/keepup_gateway/local.py +66 -0
- keepup_gateway-0.6.0/keepup_gateway/plugin.py +408 -0
- keepup_gateway-0.6.0/keepup_gateway/proxy.py +311 -0
- keepup_gateway-0.6.0/keepup_gateway/registry.py +292 -0
- keepup_gateway-0.6.0/keepup_gateway/sockets.py +226 -0
- keepup_gateway-0.6.0/keepup_gateway/static/gateway.js +257 -0
- keepup_gateway-0.6.0/keepup_gateway/table.py +304 -0
- keepup_gateway-0.6.0/keepup_gateway/tables.py +26 -0
- keepup_gateway-0.6.0/keepup_gateway.egg-info/PKG-INFO +29 -0
- keepup_gateway-0.6.0/keepup_gateway.egg-info/SOURCES.txt +22 -0
- keepup_gateway-0.6.0/keepup_gateway.egg-info/dependency_links.txt +1 -0
- keepup_gateway-0.6.0/keepup_gateway.egg-info/entry_points.txt +2 -0
- keepup_gateway-0.6.0/keepup_gateway.egg-info/requires.txt +6 -0
- keepup_gateway-0.6.0/keepup_gateway.egg-info/top_level.txt +1 -0
- keepup_gateway-0.6.0/pyproject.toml +33 -0
- keepup_gateway-0.6.0/setup.cfg +4 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keepup-gateway
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: One way in for the application's services: a route table, a proxy, a balancer and the sign-in at the edge.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: keepup-admin>=0.5.0
|
|
8
|
+
Requires-Dist: keepup-db>=0.4.0
|
|
9
|
+
Requires-Dist: fastapi<1,>=0.128
|
|
10
|
+
Requires-Dist: starlette<2,>=1.3.1
|
|
11
|
+
Requires-Dist: httpx<1,>=0.28
|
|
12
|
+
Requires-Dist: websockets<18,>=13
|
|
13
|
+
|
|
14
|
+
# keepup-gateway
|
|
15
|
+
|
|
16
|
+
One way in for the application's services: a route table, a proxy, a balancer and the sign-in at the edge.
|
|
17
|
+
|
|
18
|
+
One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
|
|
19
|
+
panels: the base is installed as `keepup-admin` and imported as `keepup`, and
|
|
20
|
+
this distribution carries one part of it. It is installed on its own
|
|
21
|
+
(`pip install keepup-gateway`) or together with the base (`pip install "keepup-admin[gateway]"`),
|
|
22
|
+
and it declares itself to the kernel through its
|
|
23
|
+
`[project.entry-points."keepup.plugins"]` entry point.
|
|
24
|
+
|
|
25
|
+
Every request comes to the application first; the gateway finds the route it is
|
|
26
|
+
addressed to, checks the sign-in and the right when the route asks for them, and
|
|
27
|
+
passes the request on to a microservice, a subsystem of the application, or any
|
|
28
|
+
other address. A service behind the gateway learns who is calling from signed
|
|
29
|
+
`X-Keepup-*` headers and checks them with `keepup_gateway.verify_identity`.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# keepup-gateway
|
|
2
|
+
|
|
3
|
+
One way in for the application's services: a route table, a proxy, a balancer and the sign-in at the edge.
|
|
4
|
+
|
|
5
|
+
One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
|
|
6
|
+
panels: the base is installed as `keepup-admin` and imported as `keepup`, and
|
|
7
|
+
this distribution carries one part of it. It is installed on its own
|
|
8
|
+
(`pip install keepup-gateway`) or together with the base (`pip install "keepup-admin[gateway]"`),
|
|
9
|
+
and it declares itself to the kernel through its
|
|
10
|
+
`[project.entry-points."keepup.plugins"]` entry point.
|
|
11
|
+
|
|
12
|
+
Every request comes to the application first; the gateway finds the route it is
|
|
13
|
+
addressed to, checks the sign-in and the right when the route asks for them, and
|
|
14
|
+
passes the request on to a microservice, a subsystem of the application, or any
|
|
15
|
+
other address. A service behind the gateway learns who is calling from signed
|
|
16
|
+
`X-Keepup-*` headers and checks them with `keepup_gateway.verify_identity`.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""One way in for the application's services.
|
|
2
|
+
|
|
3
|
+
This distribution provides `gateway` and declares one plugin, `gateway`, in the
|
|
4
|
+
`keepup.plugins` group. It depends on `keepup-admin` for the constructor and for
|
|
5
|
+
the kernel's question "who is calling" (`keepup.kernel.security`), and on nothing
|
|
6
|
+
of another capability: a deployment with no sign-in can still route.
|
|
7
|
+
|
|
8
|
+
The pure parts -- the table, the balancer, the headers and the identity a
|
|
9
|
+
service checks -- import nothing beyond the standard library, so a service
|
|
10
|
+
behind the gateway can verify what it was sent without installing FastAPI.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
__version__ = "0.6.0"
|
|
14
|
+
|
|
15
|
+
from keepup_gateway.identity import IdentityError, sign_identity, verify_identity # noqa: E402
|
|
16
|
+
|
|
17
|
+
_PLUGIN_NAMES = {"GatewayPlugin", "SERVICE_GATEWAY"}
|
|
18
|
+
|
|
19
|
+
__all__ = ["GatewayPlugin", "SERVICE_GATEWAY", "IdentityError", "sign_identity",
|
|
20
|
+
"verify_identity"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def __getattr__(name):
|
|
24
|
+
if name in _PLUGIN_NAMES:
|
|
25
|
+
from keepup_gateway import plugin
|
|
26
|
+
|
|
27
|
+
return getattr(plugin, name)
|
|
28
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""A service telling the gateway it is here, and repeating it while it is.
|
|
2
|
+
|
|
3
|
+
The application behind the gateway installs `keepup-gateway` too, and its
|
|
4
|
+
`announce` section says which gateway, under what name and prefixes, and at which
|
|
5
|
+
address it is reached. It registers at the start and again every `interval`
|
|
6
|
+
seconds, well inside the record's time to live; on a normal stop it takes its
|
|
7
|
+
address back, and on a crash the record runs out by itself.
|
|
8
|
+
|
|
9
|
+
The loop is an asyncio task of the plugin, not a job of the scheduler: a headless
|
|
10
|
+
subsystem need not install `keepup-tasks` to be found.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Any, Dict, Mapping, Optional
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from keepup_gateway.registry import (
|
|
21
|
+
ACTION_DEREGISTER,
|
|
22
|
+
ACTION_REGISTER,
|
|
23
|
+
DEFAULT_TTL_SECONDS,
|
|
24
|
+
sign_registration,
|
|
25
|
+
)
|
|
26
|
+
from keepup_gateway.table import TableError
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
__all__ = ["REGISTER_PATH", "Announcer", "parse_announce"]
|
|
31
|
+
|
|
32
|
+
REGISTER_PATH = "/api/gateway/services"
|
|
33
|
+
#: The route keys an announcement may carry to the gateway.
|
|
34
|
+
_ROUTE_KEYS = ("methods", "strip_prefix", "auth", "permission", "timeout",
|
|
35
|
+
"connect_timeout", "max_body_bytes", "websocket")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_announce(section: Optional[Mapping[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
39
|
+
"""The ``announce`` section, checked; None when there is none.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
TableError: naming what is missing.
|
|
43
|
+
"""
|
|
44
|
+
if not section:
|
|
45
|
+
return None
|
|
46
|
+
if not isinstance(section, Mapping):
|
|
47
|
+
raise TableError("gateway: announce must be an object")
|
|
48
|
+
for key in ("gateway", "name", "address"):
|
|
49
|
+
if not isinstance(section.get(key), str) or not section[key].strip():
|
|
50
|
+
raise TableError(f"gateway: announce.{key} is required")
|
|
51
|
+
prefixes = section.get("prefixes", section.get("prefix"))
|
|
52
|
+
if isinstance(prefixes, str):
|
|
53
|
+
prefixes = [prefixes]
|
|
54
|
+
if not isinstance(prefixes, list) or not prefixes \
|
|
55
|
+
or not all(isinstance(item, str) and item.strip() for item in prefixes):
|
|
56
|
+
raise TableError("gateway: announce.prefixes is required -- a list of paths, "
|
|
57
|
+
"or announce.prefix for one")
|
|
58
|
+
ttl = section.get("ttl", DEFAULT_TTL_SECONDS)
|
|
59
|
+
interval = section.get("interval", ttl / 3)
|
|
60
|
+
for key, value in (("ttl", ttl), ("interval", interval)):
|
|
61
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
|
|
62
|
+
raise TableError(f"gateway: announce.{key} must be a positive number")
|
|
63
|
+
if interval >= ttl:
|
|
64
|
+
raise TableError("gateway: announce.interval must be shorter than announce.ttl, "
|
|
65
|
+
"or the record runs out between two announcements")
|
|
66
|
+
record = {"name": section["name"].strip(),
|
|
67
|
+
"prefixes": [item.strip() for item in prefixes],
|
|
68
|
+
"address": section["address"].strip().rstrip("/"), "ttl": ttl}
|
|
69
|
+
record.update({key: section[key] for key in _ROUTE_KEYS if key in section})
|
|
70
|
+
return {"gateway": section["gateway"].strip().rstrip("/"), "interval": float(interval),
|
|
71
|
+
"record": record}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Announcer:
|
|
75
|
+
"""Registers one service with one gateway, over and over.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
settings: what :func:`parse_announce` returned.
|
|
79
|
+
secret: the secret shared with the gateway.
|
|
80
|
+
transport: an httpx transport, for a test.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, settings: Mapping[str, Any], secret: str,
|
|
84
|
+
transport: Optional[httpx.AsyncBaseTransport] = None):
|
|
85
|
+
self.gateway = settings["gateway"]
|
|
86
|
+
self.interval = settings["interval"]
|
|
87
|
+
self.record = dict(settings["record"])
|
|
88
|
+
self.secret = secret
|
|
89
|
+
self._client = httpx.AsyncClient(transport=transport, timeout=5.0)
|
|
90
|
+
self._task: Optional[asyncio.Task] = None
|
|
91
|
+
self.last_error: Optional[str] = None
|
|
92
|
+
self.registered = False
|
|
93
|
+
|
|
94
|
+
def start(self) -> None:
|
|
95
|
+
"""Begin announcing, in the background."""
|
|
96
|
+
if self._task is None:
|
|
97
|
+
self._task = asyncio.get_running_loop().create_task(self._loop())
|
|
98
|
+
|
|
99
|
+
async def stop(self) -> None:
|
|
100
|
+
"""Stop announcing and take the address back."""
|
|
101
|
+
if self._task is not None:
|
|
102
|
+
self._task.cancel()
|
|
103
|
+
try:
|
|
104
|
+
await self._task
|
|
105
|
+
except asyncio.CancelledError:
|
|
106
|
+
pass
|
|
107
|
+
self._task = None
|
|
108
|
+
await self.send(ACTION_DEREGISTER)
|
|
109
|
+
await self._client.aclose()
|
|
110
|
+
|
|
111
|
+
async def _loop(self) -> None:
|
|
112
|
+
while True:
|
|
113
|
+
await self.send(ACTION_REGISTER)
|
|
114
|
+
await asyncio.sleep(self.interval)
|
|
115
|
+
|
|
116
|
+
async def send(self, action: str) -> bool:
|
|
117
|
+
"""One registration or one withdrawal; a failure is logged and retried later."""
|
|
118
|
+
record = self.record if action == ACTION_REGISTER else {
|
|
119
|
+
"name": self.record["name"], "address": self.record["address"]}
|
|
120
|
+
body = json.dumps(record, sort_keys=True).encode("utf-8")
|
|
121
|
+
headers = {"Content-Type": "application/json",
|
|
122
|
+
**sign_registration(self.secret, action, body)}
|
|
123
|
+
method = "POST" if action == ACTION_REGISTER else "DELETE"
|
|
124
|
+
try:
|
|
125
|
+
answer = await self._client.request(method, self.gateway + REGISTER_PATH,
|
|
126
|
+
content=body, headers=headers)
|
|
127
|
+
except httpx.HTTPError as error:
|
|
128
|
+
return self._failed(f"the gateway did not answer: {error}")
|
|
129
|
+
if answer.status_code >= 300:
|
|
130
|
+
return self._failed(f"the gateway answered {answer.status_code}: "
|
|
131
|
+
f"{answer.text[:200]}")
|
|
132
|
+
if self.last_error is not None or not self.registered:
|
|
133
|
+
logger.info("Gateway: %s %s at %s", action, self.record["name"], self.gateway)
|
|
134
|
+
self.last_error = None
|
|
135
|
+
self.registered = action == ACTION_REGISTER
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
def _failed(self, reason: str) -> bool:
|
|
139
|
+
if reason != self.last_error:
|
|
140
|
+
logger.warning("Gateway announcement of %s: %s", self.record["name"], reason)
|
|
141
|
+
self.last_error = reason
|
|
142
|
+
return False
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""The applications behind the gateway, as the administrator sees them.
|
|
2
|
+
|
|
3
|
+
A route marked ``application`` stands for a keepup application of its own -- a
|
|
4
|
+
headless subsystem, a microservice built on the framework. The administrator
|
|
5
|
+
wants to see it the way the panel shows its own runtime: which plugins it runs,
|
|
6
|
+
which of them failed and why, whether it is up.
|
|
7
|
+
|
|
8
|
+
The subsystem may have no panel, no `keepup-auth` and no `keepup-modules`, so the
|
|
9
|
+
panel's own `/api/admin/plugins` -- guarded by the panel's sign-in -- is not
|
|
10
|
+
something to count on. What it has instead is `keepup-gateway`, installed with
|
|
11
|
+
the same `identity_secret`: it answers `GET /api/gateway/self` to the gateway's
|
|
12
|
+
signed identity of an administrator, with its runtime's own report. The gateway
|
|
13
|
+
asks every address of such a route for that, for `/api/health` (public in every
|
|
14
|
+
keepup application) and for `/metrics`, at once and with a short timeout -- a
|
|
15
|
+
card for a dead address says so instead of holding the page.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
from typing import Any, Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from keepup_gateway.identity import Caller, sign_identity
|
|
24
|
+
from keepup_gateway.headers import request_id_of
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"SELF_PATH",
|
|
28
|
+
"HEALTH_PATH",
|
|
29
|
+
"METRICS_PATH",
|
|
30
|
+
"PROBE_TIMEOUT_SECONDS",
|
|
31
|
+
"ADMIN_ROLE",
|
|
32
|
+
"describe_self",
|
|
33
|
+
"Prober",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
SELF_PATH = "/api/gateway/self"
|
|
37
|
+
HEALTH_PATH = "/api/health"
|
|
38
|
+
METRICS_PATH = "/metrics"
|
|
39
|
+
#: A card is a look, not a request somebody waits on: an address that does not
|
|
40
|
+
#: answer in this long is shown as not answering.
|
|
41
|
+
PROBE_TIMEOUT_SECONDS = 3.0
|
|
42
|
+
#: The role whose signed identity may read a runtime's report.
|
|
43
|
+
ADMIN_ROLE = "ADMIN"
|
|
44
|
+
#: What of a plugin's row the card shows.
|
|
45
|
+
_PLUGIN_FIELDS = ("id", "name", "kind", "distribution", "enabled", "initialized",
|
|
46
|
+
"outcome", "reason", "degraded")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def describe_self(manager: Any, settings: Any = None) -> Dict[str, Any]:
|
|
50
|
+
"""What this application says about itself to the gateway.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
manager: the runtime the plugin was loaded by (or a 0.3.0 manager).
|
|
54
|
+
settings: the application's settings, when known.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Its name, this replica, the framework's version and every plugin's state.
|
|
58
|
+
"""
|
|
59
|
+
import keepup
|
|
60
|
+
from keepup.instance import get_instance_id
|
|
61
|
+
|
|
62
|
+
settings = settings if settings is not None else getattr(manager, "settings", None)
|
|
63
|
+
return {
|
|
64
|
+
"application": getattr(settings, "project_name", None) or getattr(settings, "title", None)
|
|
65
|
+
or "keepup application",
|
|
66
|
+
"title": getattr(settings, "title", None),
|
|
67
|
+
"instance": get_instance_id(),
|
|
68
|
+
"keepup": getattr(keepup, "__version__", None),
|
|
69
|
+
"plugins": _plugins_of(manager),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _plugins_of(manager: Any) -> List[Dict[str, Any]]:
|
|
74
|
+
"""Every plugin's state, from the runtime's report or from a manager's plugins."""
|
|
75
|
+
report = getattr(manager, "report", None)
|
|
76
|
+
if callable(report):
|
|
77
|
+
return [{field: row.get(field) for field in _PLUGIN_FIELDS} for row in report()]
|
|
78
|
+
plugins = getattr(manager, "plugins", None) or {}
|
|
79
|
+
return [{"id": plugin_id, "name": getattr(plugin, "name", plugin_id),
|
|
80
|
+
"initialized": bool(getattr(plugin, "initialized", False)),
|
|
81
|
+
"outcome": "running" if getattr(plugin, "initialized", False) else None}
|
|
82
|
+
for plugin_id, plugin in dict(plugins).items()]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Prober:
|
|
86
|
+
"""Asks the addresses of application routes what they are.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
client: the gateway's HTTP client.
|
|
90
|
+
secret: what the administrator's identity is signed with.
|
|
91
|
+
timeout: seconds per question.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, client: httpx.AsyncClient, secret: str,
|
|
95
|
+
timeout: float = PROBE_TIMEOUT_SECONDS):
|
|
96
|
+
self.client = client
|
|
97
|
+
self.secret = secret
|
|
98
|
+
self.timeout = timeout
|
|
99
|
+
|
|
100
|
+
async def cards(self, routes, balancer, caller: Caller) -> List[Dict[str, Any]]:
|
|
101
|
+
"""One entry per application, each with a card per address.
|
|
102
|
+
|
|
103
|
+
A registered service holds several prefixes and is one application: its
|
|
104
|
+
routes are shown once, with all their prefixes, and each address is
|
|
105
|
+
asked once.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
routes: the table.
|
|
109
|
+
balancer: whose view of the addresses the card shows beside the answers.
|
|
110
|
+
caller: the administrator who asked, signed into each question.
|
|
111
|
+
"""
|
|
112
|
+
groups: Dict[str, Dict[str, Any]] = {}
|
|
113
|
+
for route in sorted((route for route in routes if route.application),
|
|
114
|
+
key=lambda item: (item.group or item.id, item.prefix)):
|
|
115
|
+
key = route.group or route.id
|
|
116
|
+
entry = groups.setdefault(key, {"route": f"service:{route.group}" if route.group
|
|
117
|
+
else route.id,
|
|
118
|
+
"prefixes": [], "addresses": {}, "ids": []})
|
|
119
|
+
entry["prefixes"].append(route.prefix)
|
|
120
|
+
entry["ids"].append(route.id)
|
|
121
|
+
for address in route.upstreams:
|
|
122
|
+
entry["addresses"].setdefault(address, route.id)
|
|
123
|
+
asked = [(entry, address, route_id) for entry in groups.values()
|
|
124
|
+
for address, route_id in entry["addresses"].items()]
|
|
125
|
+
answers = await asyncio.gather(*(self.card(address, caller)
|
|
126
|
+
for _, address, _ in asked))
|
|
127
|
+
out = {id(entry): {"route": entry["route"], "prefix": entry["prefixes"][0],
|
|
128
|
+
"prefixes": entry["prefixes"], "addresses": []}
|
|
129
|
+
for entry in groups.values()}
|
|
130
|
+
for (entry, address, route_id), answer in zip(asked, answers):
|
|
131
|
+
out[id(entry)]["addresses"].append({**answer, **balancer.state(route_id, address)})
|
|
132
|
+
return [out[id(entry)] for entry in groups.values()]
|
|
133
|
+
|
|
134
|
+
async def card(self, address: str, caller: Caller) -> Dict[str, Any]:
|
|
135
|
+
"""What one address answers to the three questions."""
|
|
136
|
+
own, health, metrics = await asyncio.gather(
|
|
137
|
+
self._get(address, SELF_PATH, caller),
|
|
138
|
+
self._get(address, HEALTH_PATH, None),
|
|
139
|
+
self._get(address, METRICS_PATH, caller),
|
|
140
|
+
)
|
|
141
|
+
card: Dict[str, Any] = {"address": address, "reachable": any(
|
|
142
|
+
answer["status"] is not None for answer in (own, health, metrics))}
|
|
143
|
+
card["self"] = _json_or_error(own)
|
|
144
|
+
card["health"] = {"status": health["status"], "error": health["error"],
|
|
145
|
+
"state": (_json(health) or {}).get("status")}
|
|
146
|
+
card["metrics"] = {"status": metrics["status"], "error": metrics["error"],
|
|
147
|
+
"series": _series(metrics) if metrics["status"] == 200 else None}
|
|
148
|
+
return card
|
|
149
|
+
|
|
150
|
+
async def _get(self, address: str, path: str, caller: Optional[Caller]) -> Dict[str, Any]:
|
|
151
|
+
"""One question: the status and the body, or the reason there is none."""
|
|
152
|
+
request_id = request_id_of({})
|
|
153
|
+
headers = {"X-Request-Id": request_id}
|
|
154
|
+
if caller is not None and self.secret:
|
|
155
|
+
headers.update(sign_identity(caller, self.secret, request_id))
|
|
156
|
+
try:
|
|
157
|
+
answer = await self.client.get(address + path, headers=headers,
|
|
158
|
+
timeout=self.timeout)
|
|
159
|
+
except httpx.HTTPError as error:
|
|
160
|
+
return {"status": None, "body": b"", "error": str(error)[:200] or type(error).__name__}
|
|
161
|
+
return {"status": answer.status_code, "body": answer.content, "error": None}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _json(answer: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
165
|
+
"""The body as an object, or None."""
|
|
166
|
+
import json
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
value = json.loads(answer["body"] or b"")
|
|
170
|
+
except (ValueError, TypeError):
|
|
171
|
+
return None
|
|
172
|
+
return value if isinstance(value, dict) else None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _json_or_error(answer: Dict[str, Any]) -> Dict[str, Any]:
|
|
176
|
+
"""The application's own report, or why it could not be read."""
|
|
177
|
+
if answer["status"] == 200 and _json(answer) is not None:
|
|
178
|
+
return {"status": 200, **_json(answer)}
|
|
179
|
+
reason = answer["error"]
|
|
180
|
+
if reason is None:
|
|
181
|
+
reason = {401: "the address does not accept the gateway's identity -- is keepup-gateway "
|
|
182
|
+
"installed there with the same identity_secret?",
|
|
183
|
+
403: "the identity was accepted, the role was not",
|
|
184
|
+
404: "the address has no keepup-gateway, or it has no identity_secret"
|
|
185
|
+
}.get(answer["status"], f"answered {answer['status']}")
|
|
186
|
+
return {"status": answer["status"], "error": reason}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _series(answer: Dict[str, Any]) -> int:
|
|
190
|
+
"""How many samples a Prometheus answer carries."""
|
|
191
|
+
text = (answer["body"] or b"").decode("utf-8", "replace")
|
|
192
|
+
return sum(1 for line in text.splitlines() if line and not line.startswith("#"))
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Which address of a route gets the next request.
|
|
2
|
+
|
|
3
|
+
Round robin over the addresses that have not failed lately. An address that did
|
|
4
|
+
not take a connection or did not answer in time is left out for the route's
|
|
5
|
+
cooldown. The state is this replica's own, the way a balancer's always is: each
|
|
6
|
+
replica finds out for itself whether it reaches an address, and sharing that
|
|
7
|
+
through the database would turn every proxied request into a write.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from typing import Callable, Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
__all__ = ["Balancer"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Balancer:
|
|
18
|
+
"""The turn and the cooldowns of every route, in this replica.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
clock: what tells the time; a test passes its own.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, clock: Callable[[], float] = time.monotonic):
|
|
25
|
+
self._clock = clock
|
|
26
|
+
self._lock = threading.Lock()
|
|
27
|
+
self._turn: Dict[str, int] = {}
|
|
28
|
+
self._cooling: Dict[Tuple[str, str], float] = {}
|
|
29
|
+
self._failures: Dict[Tuple[str, str], str] = {}
|
|
30
|
+
|
|
31
|
+
def order(self, route_id: str, upstreams: Tuple[str, ...]) -> List[str]:
|
|
32
|
+
"""The addresses to try for one request, the next in turn first.
|
|
33
|
+
|
|
34
|
+
Addresses that are cooling go after the live ones rather than being
|
|
35
|
+
dropped: when every address failed a moment ago, trying one is better
|
|
36
|
+
than refusing a request that might have passed.
|
|
37
|
+
"""
|
|
38
|
+
with self._lock:
|
|
39
|
+
start = self._turn.get(route_id, 0)
|
|
40
|
+
self._turn[route_id] = (start + 1) % max(1, len(upstreams))
|
|
41
|
+
now = self._clock()
|
|
42
|
+
turned = [upstreams[(start + step) % len(upstreams)]
|
|
43
|
+
for step in range(len(upstreams))]
|
|
44
|
+
live = [address for address in turned
|
|
45
|
+
if self._cooling.get((route_id, address), 0.0) <= now]
|
|
46
|
+
cooling = [address for address in turned if address not in live]
|
|
47
|
+
return live + cooling
|
|
48
|
+
|
|
49
|
+
def failed(self, route_id: str, address: str, cooldown: float, reason: str = "") -> None:
|
|
50
|
+
"""Leave an address out of the turn for a while."""
|
|
51
|
+
with self._lock:
|
|
52
|
+
self._cooling[(route_id, address)] = self._clock() + cooldown
|
|
53
|
+
self._failures[(route_id, address)] = reason
|
|
54
|
+
|
|
55
|
+
def answered(self, route_id: str, address: str) -> None:
|
|
56
|
+
"""An address answered: it is back in the turn at once."""
|
|
57
|
+
with self._lock:
|
|
58
|
+
self._cooling.pop((route_id, address), None)
|
|
59
|
+
self._failures.pop((route_id, address), None)
|
|
60
|
+
|
|
61
|
+
def state(self, route_id: str, address: str) -> Dict[str, Optional[object]]:
|
|
62
|
+
"""What the administrator's report says about one address."""
|
|
63
|
+
with self._lock:
|
|
64
|
+
until = self._cooling.get((route_id, address))
|
|
65
|
+
left = None if until is None else until - self._clock()
|
|
66
|
+
if left is not None and left <= 0:
|
|
67
|
+
left = None
|
|
68
|
+
return {
|
|
69
|
+
"address": address,
|
|
70
|
+
"state": "live" if left is None else "cooling",
|
|
71
|
+
"cooling_seconds": None if left is None else round(left, 1),
|
|
72
|
+
"last_failure": self._failures.get((route_id, address)) if left else None,
|
|
73
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""The headers that cross the gateway, in each direction.
|
|
2
|
+
|
|
3
|
+
What describes the connection rather than the message (RFC 9110 7.6.1) stays on
|
|
4
|
+
its side; what only the gateway may say about the caller is cut from the
|
|
5
|
+
client's request on every route, signed in or not -- a header a service trusts
|
|
6
|
+
must never be one a client can write. Pure: headers in, headers out.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import Iterable, List, Mapping, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from keepup_gateway.identity import REQUEST_ID, is_identity_header
|
|
14
|
+
|
|
15
|
+
__all__ = ["HOP_BY_HOP", "request_id_of", "to_upstream", "to_client"]
|
|
16
|
+
|
|
17
|
+
HOP_BY_HOP = frozenset({
|
|
18
|
+
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te",
|
|
19
|
+
"trailer", "trailers", "transfer-encoding", "upgrade", "proxy-connection",
|
|
20
|
+
})
|
|
21
|
+
#: Recomputed on each side: the body is re-framed by whoever sends it on.
|
|
22
|
+
FRAMING = frozenset({"content-length", "host"})
|
|
23
|
+
|
|
24
|
+
#: A request id a client may choose: short, and nothing a log line could be broken by.
|
|
25
|
+
_REQUEST_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
|
26
|
+
|
|
27
|
+
Pairs = List[Tuple[str, str]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def request_id_of(headers: Mapping[str, str]) -> str:
|
|
31
|
+
"""The client's request id when it is a usable one, a new one otherwise."""
|
|
32
|
+
given = _get(headers, REQUEST_ID)
|
|
33
|
+
if given and _REQUEST_ID.match(given):
|
|
34
|
+
return given
|
|
35
|
+
return uuid.uuid4().hex
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def to_upstream(pairs: Iterable[Tuple[str, str]], *, client_host: str, scheme: str,
|
|
39
|
+
host: str, prefix: str, request_id: str,
|
|
40
|
+
added: Optional[Mapping[str, str]] = None) -> Pairs:
|
|
41
|
+
"""The headers a service is sent.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
pairs: the client's headers, as (name, value) pairs -- a name may repeat.
|
|
45
|
+
client_host: the address the request came from.
|
|
46
|
+
scheme: ``http`` or ``https``, as the client used it.
|
|
47
|
+
host: the host the client asked for.
|
|
48
|
+
prefix: the route's prefix, for a service that builds its own links.
|
|
49
|
+
request_id: the id this request travels under.
|
|
50
|
+
added: the route's own headers and the signed identity.
|
|
51
|
+
"""
|
|
52
|
+
pairs = list(pairs)
|
|
53
|
+
named = _connection_names(pairs)
|
|
54
|
+
forwarded_for = None
|
|
55
|
+
out: Pairs = []
|
|
56
|
+
for name, value in pairs:
|
|
57
|
+
lowered = name.lower()
|
|
58
|
+
if lowered in HOP_BY_HOP or lowered in FRAMING or lowered in named:
|
|
59
|
+
continue
|
|
60
|
+
if is_identity_header(lowered) or lowered.startswith("x-forwarded-") \
|
|
61
|
+
or lowered == REQUEST_ID.lower():
|
|
62
|
+
if lowered == "x-forwarded-for":
|
|
63
|
+
forwarded_for = value
|
|
64
|
+
continue
|
|
65
|
+
out.append((name, value))
|
|
66
|
+
chain = f"{forwarded_for}, {client_host}" if forwarded_for else client_host
|
|
67
|
+
out.extend([
|
|
68
|
+
("X-Forwarded-For", chain),
|
|
69
|
+
("X-Forwarded-Proto", scheme),
|
|
70
|
+
("X-Forwarded-Host", host),
|
|
71
|
+
("X-Forwarded-Prefix", prefix),
|
|
72
|
+
(REQUEST_ID, request_id),
|
|
73
|
+
])
|
|
74
|
+
for name, value in (added or {}).items():
|
|
75
|
+
out.append((name, value))
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def to_client(pairs: Iterable[Tuple[str, str]], request_id: str) -> Pairs:
|
|
80
|
+
"""The headers the client is sent back: the service's, minus the connection's."""
|
|
81
|
+
pairs = list(pairs)
|
|
82
|
+
named = _connection_names(pairs)
|
|
83
|
+
out = [(name, value) for name, value in pairs
|
|
84
|
+
if name.lower() not in HOP_BY_HOP and name.lower() not in FRAMING
|
|
85
|
+
and name.lower() not in named and name.lower() != REQUEST_ID.lower()]
|
|
86
|
+
out.append((REQUEST_ID, request_id))
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _connection_names(pairs: Pairs) -> frozenset:
|
|
91
|
+
"""The headers ``Connection`` names: they belong to this hop too."""
|
|
92
|
+
names = set()
|
|
93
|
+
for name, value in pairs:
|
|
94
|
+
if name.lower() == "connection":
|
|
95
|
+
names.update(part.strip().lower() for part in value.split(",") if part.strip())
|
|
96
|
+
return frozenset(names)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _get(headers: Mapping[str, str], name: str) -> str:
|
|
100
|
+
"""A header's value, whatever the case of its name."""
|
|
101
|
+
value = headers.get(name) if hasattr(headers, "get") else None
|
|
102
|
+
if value is None and isinstance(headers, dict):
|
|
103
|
+
value = {key.lower(): item for key, item in headers.items()}.get(name.lower())
|
|
104
|
+
return str(value or "")
|