flexgate 0.1.0__py3-none-any.whl
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.
- flexgate/__init__.py +3 -0
- flexgate/__main__.py +3 -0
- flexgate/cli.py +935 -0
- flexgate/config.py +312 -0
- flexgate/doctor.py +241 -0
- flexgate/guardian.py +252 -0
- flexgate/healthcheck.py +197 -0
- flexgate/main.py +56 -0
- flexgate/migrate.py +138 -0
- flexgate/proxy.py +165 -0
- flexgate/registry.py +22 -0
- flexgate/router.py +75 -0
- flexgate/server.py +85 -0
- flexgate/service.py +1205 -0
- flexgate/settings.py +193 -0
- flexgate/sync.py +203 -0
- flexgate/update.py +188 -0
- flexgate-0.1.0.dist-info/METADATA +366 -0
- flexgate-0.1.0.dist-info/RECORD +21 -0
- flexgate-0.1.0.dist-info/WHEEL +4 -0
- flexgate-0.1.0.dist-info/entry_points.txt +2 -0
flexgate/guardian.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Port Guardian.
|
|
2
|
+
|
|
3
|
+
Monitors port 8765 and refuses to start the gateway if the port is already
|
|
4
|
+
occupied. While running, it continuously monitors the port and logs an ERROR
|
|
5
|
+
if a foreign process grabs it.
|
|
6
|
+
|
|
7
|
+
Does NOT kill other processes — it only detects and reports.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import signal
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import NamedTuple
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("flexgate.guardian")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── Types ────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
class PortOwner(NamedTuple):
|
|
28
|
+
pid: int
|
|
29
|
+
command: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── Config ───────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class GuardianConfig:
|
|
36
|
+
port: int = 8765
|
|
37
|
+
interval: float = 3.0
|
|
38
|
+
strict: bool = True
|
|
39
|
+
guardian_pid_file: str = "flexgate.guardian.pid"
|
|
40
|
+
gateway_pid: int | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── Port ownership detection ──────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
def _port_hex(port: int) -> str:
|
|
46
|
+
return f"{port:04X}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def check_port_owner(port: int) -> PortOwner | None:
|
|
50
|
+
"""Return the PID+command of the process bound to `port`, or None.
|
|
51
|
+
|
|
52
|
+
Works on Linux by parsing /proc/net/tcp and matching socket inodes
|
|
53
|
+
against /proc/<pid>/fd/* symlinks.
|
|
54
|
+
"""
|
|
55
|
+
target = f":{_port_hex(port)}"
|
|
56
|
+
inodes: set[int] = set()
|
|
57
|
+
|
|
58
|
+
for filename in ("/proc/net/tcp", "/proc/net/tcp6"):
|
|
59
|
+
try:
|
|
60
|
+
text = Path(filename).read_text()
|
|
61
|
+
except OSError:
|
|
62
|
+
continue
|
|
63
|
+
for line in text.splitlines()[1:]: # skip header line
|
|
64
|
+
parts = line.split()
|
|
65
|
+
if len(parts) < 10:
|
|
66
|
+
continue
|
|
67
|
+
local_addr = parts[1].upper()
|
|
68
|
+
if not local_addr.endswith(target):
|
|
69
|
+
continue
|
|
70
|
+
try:
|
|
71
|
+
inodes.add(int(parts[9]))
|
|
72
|
+
except ValueError:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
if not inodes:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
for entry in Path("/proc").iterdir():
|
|
79
|
+
if not entry.name.isdigit():
|
|
80
|
+
continue
|
|
81
|
+
pid = int(entry.name)
|
|
82
|
+
fd_dir = Path(f"/proc/{pid}/fd")
|
|
83
|
+
try:
|
|
84
|
+
for fd in fd_dir.iterdir():
|
|
85
|
+
try:
|
|
86
|
+
link = os.readlink(fd)
|
|
87
|
+
except OSError:
|
|
88
|
+
continue
|
|
89
|
+
for inode in inodes:
|
|
90
|
+
if link == f"socket:[{inode}]":
|
|
91
|
+
comm_path = Path(f"/proc/{pid}/comm")
|
|
92
|
+
try:
|
|
93
|
+
comm = comm_path.read_text().strip()
|
|
94
|
+
except OSError:
|
|
95
|
+
comm = f"pid-{pid}"
|
|
96
|
+
return PortOwner(pid=pid, command=comm)
|
|
97
|
+
except OSError:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_port_free(port: int) -> tuple[bool, str]:
|
|
104
|
+
"""Return (True, "") if the port is free, or (False, reason) if occupied."""
|
|
105
|
+
owner = check_port_owner(port)
|
|
106
|
+
if owner is None:
|
|
107
|
+
return True, ""
|
|
108
|
+
return False, f"Port {port} is already occupied by PID {owner.pid} ({owner.command})."
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ── Guardian lifecycle ───────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
def _write_guardian_pid(pid: int, path: str) -> None:
|
|
114
|
+
Path(path).write_text(str(pid))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _remove_guardian_pid(path: str) -> None:
|
|
118
|
+
try:
|
|
119
|
+
Path(path).unlink()
|
|
120
|
+
except FileNotFoundError:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class PortGuardian:
|
|
125
|
+
"""Background monitor for a single port.
|
|
126
|
+
|
|
127
|
+
In strict mode: exits immediately if the port is already occupied.
|
|
128
|
+
In monitor mode: logs an ERROR each time a foreign process binds the port.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(self, config: GuardianConfig) -> None:
|
|
132
|
+
self.config = config
|
|
133
|
+
self._running = True
|
|
134
|
+
self._occupied_at_start: bool | None = None
|
|
135
|
+
|
|
136
|
+
def _signal_stop(self, signum: int, _frame) -> None:
|
|
137
|
+
logger.info("Guardian received %s, shutting down", signal.Signals(signum).name)
|
|
138
|
+
self._running = False
|
|
139
|
+
|
|
140
|
+
def run(self) -> None:
|
|
141
|
+
cfg = self.config
|
|
142
|
+
pid_path = os.path.abspath(cfg.guardian_pid_file)
|
|
143
|
+
|
|
144
|
+
signal.signal(signal.SIGTERM, self._signal_stop)
|
|
145
|
+
signal.signal(signal.SIGINT, self._signal_stop)
|
|
146
|
+
|
|
147
|
+
logger.info(
|
|
148
|
+
"Guardian started, protecting port %d (strict=%s, interval=%.1fs)",
|
|
149
|
+
cfg.port, cfg.strict, cfg.interval,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Strict mode: fail immediately if port is already taken
|
|
153
|
+
if cfg.strict:
|
|
154
|
+
free, reason = is_port_free(cfg.port)
|
|
155
|
+
if not free:
|
|
156
|
+
# Also print to stderr so it surfaces in CLI output
|
|
157
|
+
sys.stderr.write(f"[Guardian] ERROR: {reason}\n")
|
|
158
|
+
sys.stderr.write("[Guardian] Please stop that process first, then run 'flexgate service start' again.\n")
|
|
159
|
+
sys.stderr.flush()
|
|
160
|
+
_remove_guardian_pid(pid_path)
|
|
161
|
+
sys.exit(1)
|
|
162
|
+
logger.info("Port %d is free — gateway can bind", cfg.port)
|
|
163
|
+
self._occupied_at_start = False
|
|
164
|
+
else:
|
|
165
|
+
self._occupied_at_start = check_port_owner(cfg.port) is not None
|
|
166
|
+
|
|
167
|
+
_write_guardian_pid(os.getpid(), pid_path)
|
|
168
|
+
|
|
169
|
+
while self._running:
|
|
170
|
+
try:
|
|
171
|
+
self._check_and_alert()
|
|
172
|
+
except Exception as exc:
|
|
173
|
+
logger.exception("Error in guardian loop: %s", exc)
|
|
174
|
+
|
|
175
|
+
time.sleep(cfg.interval)
|
|
176
|
+
|
|
177
|
+
_remove_guardian_pid(pid_path)
|
|
178
|
+
logger.info("Guardian stopped")
|
|
179
|
+
|
|
180
|
+
def _check_and_alert(self) -> None:
|
|
181
|
+
owner = check_port_owner(self.config.port)
|
|
182
|
+
if owner is None:
|
|
183
|
+
return # port is free
|
|
184
|
+
if self.config.gateway_pid and owner.pid == self.config.gateway_pid:
|
|
185
|
+
return # port is held by the gateway itself — all good
|
|
186
|
+
logger.error(
|
|
187
|
+
"Port %d is now occupied by PID %d (%s). Gateway is now unreachable!",
|
|
188
|
+
self.config.port, owner.pid, owner.command,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ── Subprocess spawner (called from cli.py) ──────────────────────────────────
|
|
193
|
+
|
|
194
|
+
def start_guardian_subprocess(
|
|
195
|
+
port: int,
|
|
196
|
+
interval: float,
|
|
197
|
+
strict: bool,
|
|
198
|
+
log_path: str,
|
|
199
|
+
pid_file: str,
|
|
200
|
+
gateway_pid: int | None = None,
|
|
201
|
+
) -> subprocess.Popen:
|
|
202
|
+
"""Spawn the guardian as a detached subprocess.
|
|
203
|
+
|
|
204
|
+
Redirects stdout/stderr to `log_path` so all guardian log lines
|
|
205
|
+
appear in flexgate.log alongside the gateway's own logs.
|
|
206
|
+
"""
|
|
207
|
+
cmd = [
|
|
208
|
+
sys.executable, "-m", "flexgate.guardian",
|
|
209
|
+
str(port), str(interval), str(strict).lower(),
|
|
210
|
+
]
|
|
211
|
+
if gateway_pid is not None:
|
|
212
|
+
cmd.append(str(gateway_pid))
|
|
213
|
+
log_file = open(log_path, "a")
|
|
214
|
+
proc = subprocess.Popen(
|
|
215
|
+
cmd,
|
|
216
|
+
stdout=log_file,
|
|
217
|
+
stderr=log_file,
|
|
218
|
+
start_new_session=True,
|
|
219
|
+
)
|
|
220
|
+
return proc
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ── Entry point: python -m flexgate.guardian ────────────────────────────────
|
|
224
|
+
|
|
225
|
+
if __name__ == "__main__":
|
|
226
|
+
logging.basicConfig(
|
|
227
|
+
level=logging.INFO,
|
|
228
|
+
format="%(asctime)s %(levelname)s flexgate.guardian: %(message)s",
|
|
229
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
if len(sys.argv) not in (4, 5):
|
|
233
|
+
sys.stderr.write(f"Usage: python -m flexgate.guardian <port> <interval> <strict> [gateway_pid]\n")
|
|
234
|
+
sys.exit(1)
|
|
235
|
+
|
|
236
|
+
port = int(sys.argv[1])
|
|
237
|
+
interval = float(sys.argv[2])
|
|
238
|
+
strict = sys.argv[3] == "true"
|
|
239
|
+
gateway_pid = int(sys.argv[4]) if len(sys.argv) == 5 else None
|
|
240
|
+
|
|
241
|
+
from flexgate.config import FLEXGATE_HOME
|
|
242
|
+
pid_file = os.path.join(FLEXGATE_HOME, "flexgate.guardian.pid")
|
|
243
|
+
|
|
244
|
+
cfg = GuardianConfig(
|
|
245
|
+
port=port,
|
|
246
|
+
interval=interval,
|
|
247
|
+
strict=strict,
|
|
248
|
+
guardian_pid_file=pid_file,
|
|
249
|
+
gateway_pid=gateway_pid,
|
|
250
|
+
)
|
|
251
|
+
guardian = PortGuardian(cfg)
|
|
252
|
+
guardian.run()
|
flexgate/healthcheck.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Provider connectivity verification.
|
|
2
|
+
|
|
3
|
+
Tests each unique ``(provider, model)`` combination referenced by the
|
|
4
|
+
configured routes (default + schedule) by issuing a minimal
|
|
5
|
+
``POST /v1/messages`` request with ``max_tokens=1``.
|
|
6
|
+
|
|
7
|
+
This catches the common pre-flight failure modes before the gateway
|
|
8
|
+
starts accepting traffic:
|
|
9
|
+
|
|
10
|
+
* DNS / TCP / TLS unreachable (wrong base_url, no network)
|
|
11
|
+
* Invalid or expired API key (401 / 403)
|
|
12
|
+
* Provider-side outage (5xx)
|
|
13
|
+
* Unknown model name for that provider (404 / 400)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import logging
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from flexgate.config import GatewayConfig, ProviderConfig
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("flexgate.healthcheck")
|
|
27
|
+
|
|
28
|
+
# Fallback model used when a provider is referenced only by routes without
|
|
29
|
+
# an explicit ``model:`` field. May produce a 404 but still exercises the
|
|
30
|
+
# auth path.
|
|
31
|
+
_FALLBACK_TEST_MODEL = "claude-haiku-4-5"
|
|
32
|
+
|
|
33
|
+
# Heuristic placeholders that the default template ships with; we don't
|
|
34
|
+
# want to hit upstream with these.
|
|
35
|
+
_PLACEHOLDER_MARKERS = ("your-", "xxx", "changeme", "placeholder")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class CheckResult:
|
|
40
|
+
provider: str
|
|
41
|
+
model: str | None
|
|
42
|
+
ok: bool
|
|
43
|
+
status: int | None
|
|
44
|
+
message: str
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def target(self) -> str:
|
|
48
|
+
if self.model:
|
|
49
|
+
return f"{self.provider} / {self.model}"
|
|
50
|
+
return self.provider
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_placeholder_key(key: str) -> bool:
|
|
54
|
+
if not key:
|
|
55
|
+
return True
|
|
56
|
+
low = key.lower()
|
|
57
|
+
return any(marker in low for marker in _PLACEHOLDER_MARKERS)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _collect_targets(config: GatewayConfig) -> list[tuple[ProviderConfig, str | None]]:
|
|
61
|
+
"""Collect unique ``(provider, model)`` targets to verify.
|
|
62
|
+
|
|
63
|
+
De-duplicates across the default routes and every schedule entry. If a
|
|
64
|
+
provider is configured but never referenced by any route, it is still
|
|
65
|
+
probed once with no model override so misconfigured base_urls/keys are
|
|
66
|
+
surfaced.
|
|
67
|
+
"""
|
|
68
|
+
seen: set[tuple[str, str | None]] = set()
|
|
69
|
+
targets: list[tuple[ProviderConfig, str | None]] = []
|
|
70
|
+
|
|
71
|
+
all_routes = list(config.routes)
|
|
72
|
+
for entry in config.schedule:
|
|
73
|
+
all_routes.extend(entry.routes)
|
|
74
|
+
|
|
75
|
+
for route in all_routes:
|
|
76
|
+
provider = config.providers.get(route.provider_name)
|
|
77
|
+
if provider is None:
|
|
78
|
+
continue
|
|
79
|
+
model = route.model or provider.default_model
|
|
80
|
+
key = (route.provider_name, model)
|
|
81
|
+
if key in seen:
|
|
82
|
+
continue
|
|
83
|
+
seen.add(key)
|
|
84
|
+
targets.append((provider, model))
|
|
85
|
+
|
|
86
|
+
covered = {name for name, _ in seen}
|
|
87
|
+
for name, prov in config.providers.items():
|
|
88
|
+
if name not in covered:
|
|
89
|
+
targets.append((prov, prov.default_model))
|
|
90
|
+
|
|
91
|
+
return targets
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def _check_one(
|
|
95
|
+
client: httpx.AsyncClient,
|
|
96
|
+
provider: ProviderConfig,
|
|
97
|
+
model: str | None,
|
|
98
|
+
timeout: float,
|
|
99
|
+
) -> CheckResult:
|
|
100
|
+
if _is_placeholder_key(provider.api_key):
|
|
101
|
+
return CheckResult(
|
|
102
|
+
provider.name, model, False, None,
|
|
103
|
+
"api_key looks like a placeholder — edit your config.yaml",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
url = f"{provider.base_url}/v1/messages"
|
|
107
|
+
headers = {
|
|
108
|
+
"content-type": "application/json",
|
|
109
|
+
"x-api-key": provider.api_key,
|
|
110
|
+
"anthropic-version": "2023-06-01",
|
|
111
|
+
}
|
|
112
|
+
body = {
|
|
113
|
+
"model": model or _FALLBACK_TEST_MODEL,
|
|
114
|
+
"max_tokens": 1,
|
|
115
|
+
"messages": [{"role": "user", "content": "ping"}],
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
resp = await client.post(url, headers=headers, json=body, timeout=timeout)
|
|
120
|
+
except httpx.ConnectError as exc:
|
|
121
|
+
return CheckResult(provider.name, model, False, None, f"connect error: {exc}")
|
|
122
|
+
except httpx.TimeoutException:
|
|
123
|
+
return CheckResult(provider.name, model, False, None, f"timeout after {timeout:g}s")
|
|
124
|
+
except httpx.HTTPError as exc:
|
|
125
|
+
return CheckResult(
|
|
126
|
+
provider.name, model, False, None,
|
|
127
|
+
f"{type(exc).__name__}: {exc}",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
status = resp.status_code
|
|
131
|
+
|
|
132
|
+
if status == 200:
|
|
133
|
+
return CheckResult(provider.name, model, True, status, "ok")
|
|
134
|
+
|
|
135
|
+
# Try to extract a structured error message.
|
|
136
|
+
detail = ""
|
|
137
|
+
try:
|
|
138
|
+
data = resp.json()
|
|
139
|
+
if isinstance(data, dict):
|
|
140
|
+
err = data.get("error", data)
|
|
141
|
+
if isinstance(err, dict):
|
|
142
|
+
etype = err.get("type", "")
|
|
143
|
+
emsg = err.get("message", "")
|
|
144
|
+
detail = f"{etype}: {emsg}".strip(": ").strip()
|
|
145
|
+
else:
|
|
146
|
+
detail = str(err)[:200]
|
|
147
|
+
except ValueError:
|
|
148
|
+
text = (resp.text or "").strip()
|
|
149
|
+
if text:
|
|
150
|
+
detail = text[:200]
|
|
151
|
+
|
|
152
|
+
message = f"HTTP {status}" + (f" — {detail}" if detail else "")
|
|
153
|
+
|
|
154
|
+
# 401/403 → auth failure (fatal). 5xx → upstream issue (fatal).
|
|
155
|
+
# 4xx with valid auth (e.g. model-not-found when we used the fallback
|
|
156
|
+
# model) → connection works; report as warning if the route had no
|
|
157
|
+
# explicit model, otherwise still fail.
|
|
158
|
+
if status in (401, 403):
|
|
159
|
+
return CheckResult(provider.name, model, False, status, message)
|
|
160
|
+
if status >= 500:
|
|
161
|
+
return CheckResult(provider.name, model, False, status, message)
|
|
162
|
+
if model is None and status in (400, 404):
|
|
163
|
+
return CheckResult(
|
|
164
|
+
provider.name, model, True, status,
|
|
165
|
+
f"reachable (HTTP {status} on fallback model probe)",
|
|
166
|
+
)
|
|
167
|
+
return CheckResult(provider.name, model, False, status, message)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def check_providers_async(
|
|
171
|
+
config: GatewayConfig,
|
|
172
|
+
timeout: float = 15.0,
|
|
173
|
+
) -> list[CheckResult]:
|
|
174
|
+
targets = _collect_targets(config)
|
|
175
|
+
if not targets:
|
|
176
|
+
return []
|
|
177
|
+
async with httpx.AsyncClient() as client:
|
|
178
|
+
tasks = [_check_one(client, p, m, timeout) for p, m in targets]
|
|
179
|
+
return await asyncio.gather(*tasks)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def check_providers(
|
|
183
|
+
config: GatewayConfig,
|
|
184
|
+
timeout: float = 15.0,
|
|
185
|
+
) -> list[CheckResult]:
|
|
186
|
+
"""Synchronously run the connectivity checks."""
|
|
187
|
+
return asyncio.run(check_providers_async(config, timeout))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def print_results(results: list[CheckResult]) -> None:
|
|
191
|
+
if not results:
|
|
192
|
+
print(" (no providers configured)")
|
|
193
|
+
return
|
|
194
|
+
width = max(len(r.target) for r in results)
|
|
195
|
+
for r in results:
|
|
196
|
+
marker = "✓" if r.ok else "✗"
|
|
197
|
+
print(f" {marker} {r.target.ljust(width)} {r.message}")
|
flexgate/main.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import uvicorn
|
|
9
|
+
|
|
10
|
+
from flexgate.config import load_config
|
|
11
|
+
from flexgate.server import create_app
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _FlexgateServer(uvicorn.Server):
|
|
15
|
+
def __init__(self, config: uvicorn.Config, config_path: str) -> None:
|
|
16
|
+
super().__init__(config)
|
|
17
|
+
self._config_path = config_path
|
|
18
|
+
|
|
19
|
+
async def startup(self, sockets=None) -> None:
|
|
20
|
+
await super().startup(sockets=sockets)
|
|
21
|
+
if self.started and os.environ.get("FLEXGATE_SERVICE") == "1":
|
|
22
|
+
try:
|
|
23
|
+
from flexgate.service import record_applied_state
|
|
24
|
+
|
|
25
|
+
record_applied_state(self._config_path)
|
|
26
|
+
except Exception:
|
|
27
|
+
logging.getLogger("flexgate").exception(
|
|
28
|
+
"Failed to record applied service state"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_server(config_path: str, port: int | None) -> None:
|
|
33
|
+
logging.basicConfig(
|
|
34
|
+
level=logging.INFO,
|
|
35
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
36
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
gateway_config = load_config(config_path)
|
|
40
|
+
port = port or gateway_config.server.port
|
|
41
|
+
app = create_app(gateway_config, config_path=config_path)
|
|
42
|
+
server_config = uvicorn.Config(
|
|
43
|
+
app,
|
|
44
|
+
host=gateway_config.server.host,
|
|
45
|
+
port=port,
|
|
46
|
+
log_level="info",
|
|
47
|
+
)
|
|
48
|
+
_FlexgateServer(server_config, config_path).run()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
parser = argparse.ArgumentParser()
|
|
53
|
+
parser.add_argument("--config", default=None)
|
|
54
|
+
parser.add_argument("--port", type=int, default=None)
|
|
55
|
+
args = parser.parse_args()
|
|
56
|
+
run_server(args.config, args.port)
|
flexgate/migrate.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Config file schema versioning and automatic migrations.
|
|
2
|
+
|
|
3
|
+
Every config.yaml carries a ``config_version`` integer. Each release that
|
|
4
|
+
changes the config schema bumps :data:`CURRENT_CONFIG_VERSION` and registers
|
|
5
|
+
one migration function per version step in :data:`MIGRATIONS`. Upgrading
|
|
6
|
+
always walks the chain one step at a time (N → N+1 → … → current), so configs
|
|
7
|
+
from any older release are upgraded through every intermediate rule.
|
|
8
|
+
|
|
9
|
+
``flexgate doctor`` reports pending migrations; ``flexgate update`` applies
|
|
10
|
+
them (with a timestamped backup) via :func:`migrate_config_file`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Callable
|
|
20
|
+
|
|
21
|
+
CURRENT_CONFIG_VERSION = 1
|
|
22
|
+
|
|
23
|
+
# ── per-step migration rules ──────────────────────────────────────
|
|
24
|
+
# Each rule takes the raw config dict at version N and returns it upgraded
|
|
25
|
+
# to version N+1. Rules must be idempotent and must not touch api keys.
|
|
26
|
+
|
|
27
|
+
def _migrate_0_to_1(data: dict) -> dict:
|
|
28
|
+
"""Stamp the initial versioned schema.
|
|
29
|
+
|
|
30
|
+
Legacy configs (written before versioning existed) have no
|
|
31
|
+
``config_version`` key. The v1 schema is what 0.1.0 already parses, so
|
|
32
|
+
this step only guarantees the top-level sections exist and records the
|
|
33
|
+
version marker.
|
|
34
|
+
"""
|
|
35
|
+
data.setdefault("server", {})
|
|
36
|
+
data.setdefault("providers", {})
|
|
37
|
+
data.setdefault("routes", [])
|
|
38
|
+
return data
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# version N → rule upgrading N to N+1
|
|
42
|
+
MIGRATIONS: dict[int, Callable[[dict], dict]] = {
|
|
43
|
+
0: _migrate_0_to_1,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class MigrationResult:
|
|
49
|
+
path: str
|
|
50
|
+
from_version: int
|
|
51
|
+
to_version: int
|
|
52
|
+
applied: list[str] = field(default_factory=list) # human-readable steps
|
|
53
|
+
backup_path: str | None = None
|
|
54
|
+
changed: bool = False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def detect_config_version(data: dict) -> int:
|
|
58
|
+
raw = data.get("config_version", 0)
|
|
59
|
+
try:
|
|
60
|
+
version = int(raw)
|
|
61
|
+
except (TypeError, ValueError):
|
|
62
|
+
raise ValueError(f"config_version must be an integer, got {raw!r}")
|
|
63
|
+
if version < 0:
|
|
64
|
+
raise ValueError(f"config_version must be >= 0, got {version}")
|
|
65
|
+
return version
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def pending_steps(version: int) -> list[int]:
|
|
69
|
+
"""Migration steps needed to bring `version` up to CURRENT_CONFIG_VERSION."""
|
|
70
|
+
return [v for v in range(version, CURRENT_CONFIG_VERSION) if v in MIGRATIONS]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def migrate_data(data: dict) -> tuple[dict, list[str]]:
|
|
74
|
+
"""Apply all pending migrations in memory. Returns (data, applied steps)."""
|
|
75
|
+
version = detect_config_version(data)
|
|
76
|
+
if version > CURRENT_CONFIG_VERSION:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"config_version {version} is newer than this flexgate supports "
|
|
79
|
+
f"({CURRENT_CONFIG_VERSION}) — upgrade flexgate first: flexgate update"
|
|
80
|
+
)
|
|
81
|
+
applied: list[str] = []
|
|
82
|
+
for step in pending_steps(version):
|
|
83
|
+
data = MIGRATIONS[step](data)
|
|
84
|
+
applied.append(f"v{step} → v{step + 1}: {MIGRATIONS[step].__doc__.splitlines()[0] if MIGRATIONS[step].__doc__ else 'migrate'}")
|
|
85
|
+
if applied or "config_version" not in data:
|
|
86
|
+
data["config_version"] = CURRENT_CONFIG_VERSION
|
|
87
|
+
return data, applied
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def read_raw_config(path: str) -> dict:
|
|
91
|
+
import yaml
|
|
92
|
+
|
|
93
|
+
with open(path) as f:
|
|
94
|
+
raw = yaml.safe_load(f)
|
|
95
|
+
if not isinstance(raw, dict):
|
|
96
|
+
raise ValueError(f"Config file {path} must be a YAML mapping")
|
|
97
|
+
return raw
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def migrate_config_file(path: str, *, backup: bool = True) -> MigrationResult:
|
|
101
|
+
"""Migrate the config file on disk to CURRENT_CONFIG_VERSION.
|
|
102
|
+
|
|
103
|
+
Writes nothing when already current. Backs up to
|
|
104
|
+
``<path>.bak-YYYYMMDD-HHMMSS`` before rewriting.
|
|
105
|
+
"""
|
|
106
|
+
data = read_raw_config(path)
|
|
107
|
+
from_version = detect_config_version(data)
|
|
108
|
+
result = MigrationResult(path=path, from_version=from_version, to_version=CURRENT_CONFIG_VERSION)
|
|
109
|
+
|
|
110
|
+
if from_version == CURRENT_CONFIG_VERSION:
|
|
111
|
+
return result
|
|
112
|
+
if from_version > CURRENT_CONFIG_VERSION:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"{path}: config_version {from_version} is newer than this flexgate "
|
|
115
|
+
f"supports ({CURRENT_CONFIG_VERSION}) — upgrade flexgate first"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
data, applied = migrate_data(data)
|
|
119
|
+
|
|
120
|
+
if backup:
|
|
121
|
+
backup_path = f"{path}.bak-{time.strftime('%Y%m%d-%H%M%S')}"
|
|
122
|
+
shutil.copy2(path, backup_path)
|
|
123
|
+
result.backup_path = backup_path
|
|
124
|
+
|
|
125
|
+
# Keep config_version at the top for readability.
|
|
126
|
+
out = {"config_version": data.pop("config_version")}
|
|
127
|
+
out.update(data)
|
|
128
|
+
|
|
129
|
+
import yaml
|
|
130
|
+
|
|
131
|
+
tmp_path = f"{path}.tmp-{os.getpid()}"
|
|
132
|
+
with open(tmp_path, "w") as f:
|
|
133
|
+
yaml.dump(out, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
134
|
+
os.replace(tmp_path, path)
|
|
135
|
+
|
|
136
|
+
result.applied = applied
|
|
137
|
+
result.changed = True
|
|
138
|
+
return result
|