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/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()
@@ -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