fleet-framework 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.
- fleet/__init__.py +1 -0
- fleet/cli.py +290 -0
- fleet/core/__init__.py +69 -0
- fleet/core/automation.py +125 -0
- fleet/core/backend.py +736 -0
- fleet/core/config.py +38 -0
- fleet/core/context.py +102 -0
- fleet/core/contract.py +87 -0
- fleet/core/country_presets.py +50 -0
- fleet/core/events.py +55 -0
- fleet/core/logging.py +97 -0
- fleet/core/memory_backend.py +492 -0
- fleet/core/metrics.py +61 -0
- fleet/core/otel.py +97 -0
- fleet/core/primitives.py +310 -0
- fleet/core/protocol.py +171 -0
- fleet/core/proxy.py +166 -0
- fleet/core/reconcile.py +75 -0
- fleet/core/sqlite_backend.py +1117 -0
- fleet/core/store.py +104 -0
- fleet/master/__init__.py +3 -0
- fleet/master/api.py +324 -0
- fleet/master/app.py +105 -0
- fleet/master/auth.py +132 -0
- fleet/master/broadcaster.py +37 -0
- fleet/master/dashboard/__init__.py +4 -0
- fleet/master/dashboard/router.py +36 -0
- fleet/master/dashboard/static/style.css +97 -0
- fleet/master/dashboard/templates/index.html +372 -0
- fleet/master/metrics_route.py +141 -0
- fleet/master/ratelimit.py +55 -0
- fleet/master/ws_router.py +142 -0
- fleet/worker/__init__.py +3 -0
- fleet/worker/agent.py +173 -0
- fleet/worker/reconcile_loop.py +246 -0
- fleet/worker/slot_runner.py +256 -0
- fleet/worker/ws_client.py +164 -0
- fleet_browser/__init__.py +21 -0
- fleet_browser/browser.py +277 -0
- fleet_browser/cert.py +68 -0
- fleet_browser/fingerprint.py +327 -0
- fleet_browser/humanizer.py +157 -0
- fleet_browser/pool.py +241 -0
- fleet_browser/proxy_extension.py +122 -0
- fleet_browser/solver.py +51 -0
- fleet_browser/stealth.py +80 -0
- fleet_cloudflare/__init__.py +22 -0
- fleet_cloudflare/bypasser.py +168 -0
- fleet_cloudflare/harvest.py +266 -0
- fleet_cloudflare/replay.py +82 -0
- fleet_cloudflare/solver.py +28 -0
- fleet_content/__init__.py +24 -0
- fleet_content/automation.py +43 -0
- fleet_content/contracts.py +76 -0
- fleet_detect/__init__.py +26 -0
- fleet_detect/contracts.py +67 -0
- fleet_detect/detect.py +126 -0
- fleet_framework-0.1.0.dist-info/METADATA +160 -0
- fleet_framework-0.1.0.dist-info/RECORD +85 -0
- fleet_framework-0.1.0.dist-info/WHEEL +5 -0
- fleet_framework-0.1.0.dist-info/entry_points.txt +9 -0
- fleet_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
- fleet_framework-0.1.0.dist-info/top_level.txt +14 -0
- fleet_headers/__init__.py +28 -0
- fleet_headers/profiles.py +131 -0
- fleet_jobs/__init__.py +28 -0
- fleet_jobs/automation.py +34 -0
- fleet_jobs/contracts.py +143 -0
- fleet_marketplace/__init__.py +33 -0
- fleet_marketplace/automation.py +32 -0
- fleet_marketplace/contracts.py +151 -0
- fleet_news/__init__.py +21 -0
- fleet_news/automation.py +51 -0
- fleet_news/contracts.py +59 -0
- fleet_place/__init__.py +33 -0
- fleet_place/automation.py +37 -0
- fleet_place/contracts.py +156 -0
- fleet_provider_dataimpulse/__init__.py +82 -0
- fleet_provider_evomi/__init__.py +76 -0
- fleet_serp/__init__.py +30 -0
- fleet_serp/automation.py +47 -0
- fleet_serp/contracts.py +100 -0
- fleet_social/__init__.py +34 -0
- fleet_social/automation.py +44 -0
- fleet_social/contracts.py +172 -0
fleet/core/primitives.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import uuid
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, AsyncIterator, Generic, Optional, TypeVar
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ValidationError
|
|
11
|
+
|
|
12
|
+
from fleet.core.backend import Backend, RawItem
|
|
13
|
+
from fleet.core.contract import Pool, Queue, Stream, validate_tag_filter
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
P = TypeVar("P", bound=BaseModel)
|
|
18
|
+
T = TypeVar("T", bound=BaseModel)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class PoolItem(Generic[P, T]):
|
|
24
|
+
id: str
|
|
25
|
+
payload: P
|
|
26
|
+
tags: T
|
|
27
|
+
expires_at: Optional[float]
|
|
28
|
+
_handle: "PoolHandle[P, T]"
|
|
29
|
+
|
|
30
|
+
async def release(self) -> None:
|
|
31
|
+
await self._handle._backend.pool_release(self._handle._spec.name, self.id)
|
|
32
|
+
|
|
33
|
+
async def evict(self) -> None:
|
|
34
|
+
await self._handle._backend.pool_remove(self._handle._spec.name, self.id)
|
|
35
|
+
|
|
36
|
+
async def mark_bad(self, reason: str) -> None:
|
|
37
|
+
"""Evict and log. Plugins can override later via a hook for stats."""
|
|
38
|
+
logger.info("pool %s item %s marked bad: %s", self._handle._spec.name, self.id, reason)
|
|
39
|
+
await self.evict()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PoolFilter(Generic[P, T]):
|
|
43
|
+
def __init__(self, handle: "PoolHandle[P, T]", conditions: dict[str, Any]) -> None:
|
|
44
|
+
self._handle = handle
|
|
45
|
+
self._conditions = conditions
|
|
46
|
+
|
|
47
|
+
def where(self, **kwargs: Any) -> "PoolFilter[P, T]":
|
|
48
|
+
# validate kwargs against tag schema; chainable.
|
|
49
|
+
validated = validate_tag_filter(self._handle._spec.tags, **kwargs)
|
|
50
|
+
return PoolFilter(self._handle, {**self._conditions, **validated})
|
|
51
|
+
|
|
52
|
+
async def list(self) -> list[PoolItem[P, T]]:
|
|
53
|
+
raws = await self._handle._backend.pool_list(self._handle._spec.name)
|
|
54
|
+
out: list[PoolItem[P, T]] = []
|
|
55
|
+
for r in raws:
|
|
56
|
+
if r.in_use_until is not None:
|
|
57
|
+
continue
|
|
58
|
+
if not all(r.tags.get(k) == v for k, v in self._conditions.items()):
|
|
59
|
+
continue
|
|
60
|
+
item = self._handle._wrap(r)
|
|
61
|
+
if item is not None:
|
|
62
|
+
out.append(item)
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
async def size(self) -> int:
|
|
66
|
+
return len(await self.list())
|
|
67
|
+
|
|
68
|
+
@asynccontextmanager
|
|
69
|
+
async def claim(self, *, hold_seconds: int = 30) -> AsyncIterator[Optional[PoolItem[P, T]]]:
|
|
70
|
+
raw = await self._handle._backend.pool_claim_any(
|
|
71
|
+
self._handle._spec.name, self._conditions, hold_seconds,
|
|
72
|
+
)
|
|
73
|
+
if raw is None:
|
|
74
|
+
yield None
|
|
75
|
+
return
|
|
76
|
+
item = self._handle._wrap(raw)
|
|
77
|
+
if item is None:
|
|
78
|
+
# decode failure — release the claim, treat as miss
|
|
79
|
+
await self._handle._backend.pool_release(self._handle._spec.name, raw.id)
|
|
80
|
+
yield None
|
|
81
|
+
return
|
|
82
|
+
try:
|
|
83
|
+
yield item
|
|
84
|
+
finally:
|
|
85
|
+
# if the user evicted, the claim is gone too. release is idempotent.
|
|
86
|
+
try:
|
|
87
|
+
await self._handle._backend.pool_release(self._handle._spec.name, raw.id)
|
|
88
|
+
except Exception:
|
|
89
|
+
logger.debug("pool release failed", exc_info=True)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PoolHandle(Generic[P, T]):
|
|
93
|
+
def __init__(self, spec: Pool[P, T], backend: Backend) -> None:
|
|
94
|
+
self._spec = spec
|
|
95
|
+
self._backend = backend
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def name(self) -> str:
|
|
99
|
+
return self._spec.name
|
|
100
|
+
|
|
101
|
+
def where(self, **kwargs: Any) -> PoolFilter[P, T]:
|
|
102
|
+
return PoolFilter(self, {}).where(**kwargs)
|
|
103
|
+
|
|
104
|
+
async def put(
|
|
105
|
+
self,
|
|
106
|
+
*,
|
|
107
|
+
payload: P,
|
|
108
|
+
tags: T,
|
|
109
|
+
ttl_seconds: Optional[int] = None,
|
|
110
|
+
id: Optional[str] = None,
|
|
111
|
+
) -> str:
|
|
112
|
+
# local pydantic validation (re-construct enforces type) plus tags type check.
|
|
113
|
+
if not isinstance(payload, self._spec.payload):
|
|
114
|
+
raise TypeError(
|
|
115
|
+
f"pool {self._spec.name}: payload must be {self._spec.payload.__name__}, "
|
|
116
|
+
f"got {type(payload).__name__}"
|
|
117
|
+
)
|
|
118
|
+
if not isinstance(tags, self._spec.tags):
|
|
119
|
+
raise TypeError(
|
|
120
|
+
f"pool {self._spec.name}: tags must be {self._spec.tags.__name__}, "
|
|
121
|
+
f"got {type(tags).__name__}"
|
|
122
|
+
)
|
|
123
|
+
item_id = id or uuid.uuid4().hex
|
|
124
|
+
await self._backend.pool_put(
|
|
125
|
+
self._spec.name,
|
|
126
|
+
item_id,
|
|
127
|
+
payload.model_dump_json().encode("utf-8"),
|
|
128
|
+
tags.model_dump(mode="json"),
|
|
129
|
+
ttl_seconds,
|
|
130
|
+
)
|
|
131
|
+
return item_id
|
|
132
|
+
|
|
133
|
+
async def list(self) -> list[PoolItem[P, T]]:
|
|
134
|
+
return await PoolFilter(self, {}).list()
|
|
135
|
+
|
|
136
|
+
async def size(self) -> int:
|
|
137
|
+
return await PoolFilter(self, {}).size()
|
|
138
|
+
|
|
139
|
+
def _wrap(self, raw: RawItem) -> Optional[PoolItem[P, T]]:
|
|
140
|
+
try:
|
|
141
|
+
payload = self._spec.payload.model_validate_json(raw.payload)
|
|
142
|
+
tags = self._spec.tags.model_validate(raw.tags)
|
|
143
|
+
except ValidationError:
|
|
144
|
+
logger.warning("pool %s item %s failed schema validation; dropping", self._spec.name, raw.id)
|
|
145
|
+
return None
|
|
146
|
+
return PoolItem(
|
|
147
|
+
id=raw.id,
|
|
148
|
+
payload=payload,
|
|
149
|
+
tags=tags,
|
|
150
|
+
expires_at=raw.expires_at,
|
|
151
|
+
_handle=self,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class KV:
|
|
157
|
+
def __init__(self, backend: Backend, namespace: str) -> None:
|
|
158
|
+
self._backend = backend
|
|
159
|
+
self._ns = namespace
|
|
160
|
+
|
|
161
|
+
async def set(self, key: str, value: Any, *, ttl_seconds: Optional[int] = None) -> None:
|
|
162
|
+
# value can be a pydantic model, dict, str, int, ..., serialized as json
|
|
163
|
+
import json
|
|
164
|
+
if hasattr(value, "model_dump_json"):
|
|
165
|
+
blob = value.model_dump_json().encode("utf-8")
|
|
166
|
+
else:
|
|
167
|
+
blob = json.dumps(value).encode("utf-8")
|
|
168
|
+
await self._backend.kv_set(self._ns, key, blob, ttl_seconds)
|
|
169
|
+
|
|
170
|
+
async def get(self, key: str) -> Any:
|
|
171
|
+
import json
|
|
172
|
+
raw = await self._backend.kv_get(self._ns, key)
|
|
173
|
+
if raw is None:
|
|
174
|
+
return None
|
|
175
|
+
try:
|
|
176
|
+
return json.loads(raw.decode("utf-8"))
|
|
177
|
+
except Exception:
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
async def get_as(self, key: str, model: type[BaseModel]) -> Optional[BaseModel]:
|
|
181
|
+
raw = await self._backend.kv_get(self._ns, key)
|
|
182
|
+
if raw is None:
|
|
183
|
+
return None
|
|
184
|
+
return model.model_validate_json(raw)
|
|
185
|
+
|
|
186
|
+
async def delete(self, key: str) -> bool:
|
|
187
|
+
return await self._backend.kv_del(self._ns, key)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class Lock:
|
|
192
|
+
def __init__(self, backend: Backend, name: str, hold_seconds: int, wait_seconds: float) -> None:
|
|
193
|
+
self._backend = backend
|
|
194
|
+
self._name = name
|
|
195
|
+
self._hold = hold_seconds
|
|
196
|
+
self._wait = wait_seconds
|
|
197
|
+
self._token: Optional[str] = None
|
|
198
|
+
|
|
199
|
+
async def __aenter__(self) -> bool:
|
|
200
|
+
self._token = await self._backend.lock_acquire(self._name, self._hold, self._wait)
|
|
201
|
+
return self._token is not None
|
|
202
|
+
|
|
203
|
+
async def __aexit__(self, *exc) -> None:
|
|
204
|
+
if self._token is not None:
|
|
205
|
+
try:
|
|
206
|
+
await self._backend.lock_release(self._name, self._token)
|
|
207
|
+
except Exception:
|
|
208
|
+
logger.debug("lock release failed", exc_info=True)
|
|
209
|
+
self._token = None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class Counter:
|
|
214
|
+
def __init__(self, backend: Backend, name: str) -> None:
|
|
215
|
+
self._backend = backend
|
|
216
|
+
self._name = name
|
|
217
|
+
|
|
218
|
+
async def incr(self, by: int = 1) -> int:
|
|
219
|
+
return await self._backend.counter_incr(self._name, by)
|
|
220
|
+
|
|
221
|
+
async def decr(self, by: int = 1) -> int:
|
|
222
|
+
return await self._backend.counter_incr(self._name, -by)
|
|
223
|
+
|
|
224
|
+
async def get(self) -> int:
|
|
225
|
+
return await self._backend.counter_get(self._name)
|
|
226
|
+
|
|
227
|
+
async def set(self, value: int) -> None:
|
|
228
|
+
await self._backend.counter_set(self._name, value)
|
|
229
|
+
|
|
230
|
+
async def reset(self) -> None:
|
|
231
|
+
await self._backend.counter_del(self._name)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class QueueHandle(Generic[P]):
|
|
236
|
+
def __init__(self, spec: Queue[P], backend: Backend) -> None:
|
|
237
|
+
self._spec = spec
|
|
238
|
+
self._backend = backend
|
|
239
|
+
|
|
240
|
+
async def push(
|
|
241
|
+
self,
|
|
242
|
+
payload: P,
|
|
243
|
+
*,
|
|
244
|
+
task_id: Optional[str] = None,
|
|
245
|
+
priority: int = 0,
|
|
246
|
+
ttl_seconds: Optional[int] = None,
|
|
247
|
+
) -> str:
|
|
248
|
+
if not isinstance(payload, self._spec.payload):
|
|
249
|
+
raise TypeError(
|
|
250
|
+
f"queue {self._spec.name}: payload must be {self._spec.payload.__name__}, "
|
|
251
|
+
f"got {type(payload).__name__}"
|
|
252
|
+
)
|
|
253
|
+
import time
|
|
254
|
+
tid = task_id or uuid.uuid4().hex
|
|
255
|
+
envelope: dict[str, Any] = {
|
|
256
|
+
"id": tid,
|
|
257
|
+
"payload": payload.model_dump(mode="json"),
|
|
258
|
+
"attempt": 0,
|
|
259
|
+
"submitted_at": time.time(),
|
|
260
|
+
}
|
|
261
|
+
if ttl_seconds is not None:
|
|
262
|
+
envelope["ttl_seconds"] = ttl_seconds
|
|
263
|
+
body = json.dumps(envelope).encode("utf-8")
|
|
264
|
+
await self._backend.queue_push(self._spec.name, body, priority=priority)
|
|
265
|
+
return tid
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class StreamReader(Generic[P]):
|
|
270
|
+
def __init__(self, spec: Stream[P], backend: Backend) -> None:
|
|
271
|
+
self._spec = spec
|
|
272
|
+
self._backend = backend
|
|
273
|
+
|
|
274
|
+
async def pop(self, n: int = 1) -> list[P]:
|
|
275
|
+
raw_list = await self._backend.stream_pop(self._spec.name, n)
|
|
276
|
+
out: list[P] = []
|
|
277
|
+
for raw in raw_list:
|
|
278
|
+
try:
|
|
279
|
+
envelope = json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw)
|
|
280
|
+
out.append(self._spec.payload.model_validate(envelope.get("payload", {})))
|
|
281
|
+
except (json.JSONDecodeError, ValidationError):
|
|
282
|
+
logger.warning("stream %s: bad item, dropping", self._spec.name)
|
|
283
|
+
continue
|
|
284
|
+
return out
|
|
285
|
+
|
|
286
|
+
async def peek(self, n: int = 10) -> list[P]:
|
|
287
|
+
raw_list = await self._backend.stream_peek(self._spec.name, n)
|
|
288
|
+
out: list[P] = []
|
|
289
|
+
for raw in raw_list:
|
|
290
|
+
try:
|
|
291
|
+
envelope = json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw)
|
|
292
|
+
out.append(self._spec.payload.model_validate(envelope.get("payload", {})))
|
|
293
|
+
except (json.JSONDecodeError, ValidationError):
|
|
294
|
+
continue
|
|
295
|
+
return out
|
|
296
|
+
|
|
297
|
+
async def length(self) -> int:
|
|
298
|
+
return await self._backend.stream_length(self._spec.name)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
__all__ = [
|
|
302
|
+
"Counter",
|
|
303
|
+
"KV",
|
|
304
|
+
"Lock",
|
|
305
|
+
"PoolFilter",
|
|
306
|
+
"PoolHandle",
|
|
307
|
+
"PoolItem",
|
|
308
|
+
"QueueHandle",
|
|
309
|
+
"StreamReader",
|
|
310
|
+
]
|
fleet/core/protocol.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Annotated, Any, Literal, Union
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
7
|
+
|
|
8
|
+
PROTOCOL_VERSION: int = 1
|
|
9
|
+
|
|
10
|
+
WorkerState = Literal["IDLE", "RUNNING", "ERROR", "DRAINING"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProtocolError(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _Frame(BaseModel):
|
|
18
|
+
model_config = ConfigDict(extra="forbid")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Hardware(BaseModel):
|
|
22
|
+
model_config = ConfigDict(extra="forbid")
|
|
23
|
+
cores: int = Field(ge=1)
|
|
24
|
+
ram_gb: int = Field(ge=0)
|
|
25
|
+
hostname: str
|
|
26
|
+
kernel: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# worker -> master
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Register(_Frame):
|
|
33
|
+
type: Literal["register"] = "register"
|
|
34
|
+
worker_id: str
|
|
35
|
+
automation_type: str
|
|
36
|
+
protocol_version: int
|
|
37
|
+
hardware: Hardware
|
|
38
|
+
fleet_core_version: str = ""
|
|
39
|
+
"""Worker's installed fleet-core version. Master logs a mismatch warning
|
|
40
|
+
when the major differs from its own. Empty for backwards compat with
|
|
41
|
+
pre-0.1 workers."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class State(_Frame):
|
|
45
|
+
type: Literal["state"] = "state"
|
|
46
|
+
state: WorkerState
|
|
47
|
+
config_gen: int = Field(ge=0)
|
|
48
|
+
ts: int = Field(ge=0)
|
|
49
|
+
last_error: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SlotStats(_Frame):
|
|
53
|
+
"""Per-slot snapshot inside a Stats frame."""
|
|
54
|
+
slot_id: int = Field(ge=0)
|
|
55
|
+
age_seconds: float = Field(ge=0.0)
|
|
56
|
+
cpu_pct: float = Field(ge=0.0, default=0.0)
|
|
57
|
+
rss_mb: int = Field(ge=0, default=0)
|
|
58
|
+
counters: dict[str, int] = Field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Stats(_Frame):
|
|
62
|
+
type: Literal["stats"] = "stats"
|
|
63
|
+
slots: int = Field(ge=0)
|
|
64
|
+
cpu_pct: float = Field(ge=0.0)
|
|
65
|
+
rss_mb: int = Field(ge=0)
|
|
66
|
+
ts: int = Field(ge=0)
|
|
67
|
+
metrics: dict[str, Any] = Field(default_factory=dict)
|
|
68
|
+
per_slot: list[SlotStats] = Field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Output(_Frame):
|
|
72
|
+
# generic worker -> master output frame. payload is plugin-typed (validated server-side).
|
|
73
|
+
type: Literal["output"] = "output"
|
|
74
|
+
slot_id: int = Field(ge=0)
|
|
75
|
+
ts: int = Field(ge=0)
|
|
76
|
+
payload: dict[str, Any]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# master -> worker
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ConfigChanged(_Frame):
|
|
83
|
+
type: Literal["config_changed"] = "config_changed"
|
|
84
|
+
config_gen: int = Field(ge=0)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TaskAssign(_Frame):
|
|
88
|
+
# used by BatchAutomation. master pushes a single task to a worker slot.
|
|
89
|
+
type: Literal["task_assign"] = "task_assign"
|
|
90
|
+
task_id: str
|
|
91
|
+
payload: dict[str, Any]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Ping(_Frame):
|
|
95
|
+
type: Literal["ping"] = "ping"
|
|
96
|
+
id: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Drain(_Frame):
|
|
100
|
+
"""Master → worker: drain in-flight slots and exit cleanly.
|
|
101
|
+
|
|
102
|
+
After receiving, the worker sets shutdown on every slot, waits for
|
|
103
|
+
them to complete (no hard cancel), reports DRAINING, then exits.
|
|
104
|
+
"""
|
|
105
|
+
type: Literal["drain"] = "drain"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
Message = Annotated[
|
|
109
|
+
Union[Register, State, Stats, Output, ConfigChanged, TaskAssign, Ping, Drain],
|
|
110
|
+
Field(discriminator="type"),
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
_TYPE_REGISTRY: dict[str, type[BaseModel]] = {
|
|
115
|
+
"register": Register,
|
|
116
|
+
"state": State,
|
|
117
|
+
"stats": Stats,
|
|
118
|
+
"output": Output,
|
|
119
|
+
"config_changed": ConfigChanged,
|
|
120
|
+
"task_assign": TaskAssign,
|
|
121
|
+
"ping": Ping,
|
|
122
|
+
"drain": Drain,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def encode(msg: BaseModel) -> str:
|
|
127
|
+
if not isinstance(msg, BaseModel):
|
|
128
|
+
raise TypeError(f"encode() expected pydantic BaseModel, got {type(msg).__name__}")
|
|
129
|
+
return msg.model_dump_json()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def decode(raw: str | bytes | bytearray) -> BaseModel:
|
|
133
|
+
if not isinstance(raw, (str, bytes, bytearray)):
|
|
134
|
+
raise ProtocolError(f"decode() expects str/bytes, got {type(raw).__name__}")
|
|
135
|
+
try:
|
|
136
|
+
data: Any = json.loads(raw)
|
|
137
|
+
except json.JSONDecodeError as e:
|
|
138
|
+
raise ProtocolError(f"malformed JSON: {e}") from e
|
|
139
|
+
if not isinstance(data, dict):
|
|
140
|
+
raise ProtocolError(f"frame root must be object, got {type(data).__name__}")
|
|
141
|
+
if "type" not in data:
|
|
142
|
+
raise ProtocolError("frame missing required field 'type'")
|
|
143
|
+
tag = data["type"]
|
|
144
|
+
if not isinstance(tag, str):
|
|
145
|
+
raise ProtocolError(f"frame 'type' must be string, got {type(tag).__name__}")
|
|
146
|
+
cls = _TYPE_REGISTRY.get(tag)
|
|
147
|
+
if cls is None:
|
|
148
|
+
raise ProtocolError(f"unknown message type: {tag!r}")
|
|
149
|
+
try:
|
|
150
|
+
return cls.model_validate(data)
|
|
151
|
+
except ValidationError as e:
|
|
152
|
+
raise ProtocolError(f"validation failed for type={tag!r}: {e}") from e
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
__all__ = [
|
|
156
|
+
"ConfigChanged",
|
|
157
|
+
"Drain",
|
|
158
|
+
"Hardware",
|
|
159
|
+
"Message",
|
|
160
|
+
"Output",
|
|
161
|
+
"PROTOCOL_VERSION",
|
|
162
|
+
"Ping",
|
|
163
|
+
"ProtocolError",
|
|
164
|
+
"Register",
|
|
165
|
+
"State",
|
|
166
|
+
"Stats",
|
|
167
|
+
"TaskAssign",
|
|
168
|
+
"WorkerState",
|
|
169
|
+
"decode",
|
|
170
|
+
"encode",
|
|
171
|
+
]
|
fleet/core/proxy.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.metadata as md
|
|
4
|
+
import logging
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, AsyncIterator, ClassVar, Optional, Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ProxySession:
|
|
14
|
+
url: str
|
|
15
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
16
|
+
_release: Optional[Any] = None
|
|
17
|
+
_report_bad: Optional[Any] = None
|
|
18
|
+
|
|
19
|
+
async def release(self) -> None:
|
|
20
|
+
if self._release is not None:
|
|
21
|
+
await self._release(self)
|
|
22
|
+
|
|
23
|
+
async def report_bad(self, reason: str) -> None:
|
|
24
|
+
if self._report_bad is not None:
|
|
25
|
+
await self._report_bad(self, reason)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@runtime_checkable
|
|
29
|
+
class ProxyProvider(Protocol):
|
|
30
|
+
name: ClassVar[str]
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_config(cls, cfg: dict[str, Any]) -> "ProxyProvider": ...
|
|
34
|
+
|
|
35
|
+
async def acquire(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
slot_id: int,
|
|
39
|
+
sticky: bool = True,
|
|
40
|
+
country: Optional[str | list[str]] = None,
|
|
41
|
+
) -> ProxySession: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ProxyHandle:
|
|
45
|
+
# what ctx.proxy returns. wraps a provider and the slot's identity.
|
|
46
|
+
def __init__(self, provider: Optional[ProxyProvider], slot_id: int) -> None:
|
|
47
|
+
self._provider = provider
|
|
48
|
+
self._slot_id = slot_id
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def provider(self) -> Optional[ProxyProvider]:
|
|
52
|
+
return self._provider
|
|
53
|
+
|
|
54
|
+
@asynccontextmanager
|
|
55
|
+
async def session(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
sticky: bool = True,
|
|
59
|
+
country: Optional[str | list[str]] = None,
|
|
60
|
+
) -> AsyncIterator[ProxySession]:
|
|
61
|
+
if self._provider is None:
|
|
62
|
+
yield ProxySession(url="")
|
|
63
|
+
return
|
|
64
|
+
sess = await self._provider.acquire(
|
|
65
|
+
slot_id=self._slot_id, sticky=sticky, country=country
|
|
66
|
+
)
|
|
67
|
+
try:
|
|
68
|
+
yield sess
|
|
69
|
+
finally:
|
|
70
|
+
try:
|
|
71
|
+
await sess.release()
|
|
72
|
+
except Exception:
|
|
73
|
+
logger.debug("proxy session release failed", exc_info=True)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_REGISTRY: dict[str, type] = {}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def register_provider(name: str):
|
|
80
|
+
def _wrap(cls):
|
|
81
|
+
if not hasattr(cls, "from_config"):
|
|
82
|
+
raise TypeError(f"{cls.__name__} must define classmethod from_config")
|
|
83
|
+
cls.name = name
|
|
84
|
+
if name in _REGISTRY and _REGISTRY[name] is not cls:
|
|
85
|
+
raise ValueError(f"proxy provider '{name}' already registered")
|
|
86
|
+
_REGISTRY[name] = cls
|
|
87
|
+
return cls
|
|
88
|
+
|
|
89
|
+
return _wrap
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_provider_registry() -> dict[str, type]:
|
|
93
|
+
return dict(_REGISTRY)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def load_provider_entry_points(group: str = "fleet.providers.proxy") -> dict[str, type]:
|
|
97
|
+
try:
|
|
98
|
+
eps = md.entry_points(group=group)
|
|
99
|
+
except TypeError:
|
|
100
|
+
eps = md.entry_points().get(group, []) # type: ignore[assignment]
|
|
101
|
+
for ep in eps:
|
|
102
|
+
try:
|
|
103
|
+
ep.load()
|
|
104
|
+
except Exception:
|
|
105
|
+
logger.exception("failed to load proxy provider entry-point %s", ep.name)
|
|
106
|
+
return get_provider_registry()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_provider(name: str, cfg: dict[str, Any]) -> ProxyProvider:
|
|
110
|
+
load_provider_entry_points()
|
|
111
|
+
cls = _REGISTRY.get(name)
|
|
112
|
+
if cls is None:
|
|
113
|
+
known = ", ".join(sorted(_REGISTRY.keys())) or "(none installed)"
|
|
114
|
+
raise ValueError(f"unknown proxy provider '{name}'. installed: {known}")
|
|
115
|
+
return cls.from_config(cfg)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@register_provider("static")
|
|
120
|
+
class StaticProvider:
|
|
121
|
+
# the original behavior: one URL, always the same.
|
|
122
|
+
def __init__(self, url: Optional[str]) -> None:
|
|
123
|
+
self._url = url
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def from_config(cls, cfg: dict[str, Any]) -> "StaticProvider":
|
|
127
|
+
return cls(url=cfg.get("url"))
|
|
128
|
+
|
|
129
|
+
async def acquire(
|
|
130
|
+
self,
|
|
131
|
+
*,
|
|
132
|
+
slot_id: int,
|
|
133
|
+
sticky: bool = True,
|
|
134
|
+
country: Optional[str | list[str]] = None,
|
|
135
|
+
) -> ProxySession:
|
|
136
|
+
return ProxySession(url=self._url or "")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@register_provider("rotating-list")
|
|
141
|
+
class RotatingListProvider:
|
|
142
|
+
def __init__(self, urls: list[str]) -> None:
|
|
143
|
+
self._urls = urls
|
|
144
|
+
self._next = 0
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def from_config(cls, cfg: dict[str, Any]) -> "RotatingListProvider":
|
|
148
|
+
urls = list(cfg.get("urls") or [])
|
|
149
|
+
if not urls:
|
|
150
|
+
raise ValueError("rotating-list provider requires non-empty 'urls' list")
|
|
151
|
+
return cls(urls=urls)
|
|
152
|
+
|
|
153
|
+
async def acquire(
|
|
154
|
+
self,
|
|
155
|
+
*,
|
|
156
|
+
slot_id: int,
|
|
157
|
+
sticky: bool = True,
|
|
158
|
+
country: Optional[str | list[str]] = None,
|
|
159
|
+
) -> ProxySession:
|
|
160
|
+
if sticky:
|
|
161
|
+
# deterministic by slot_id so the same slot keeps the same URL.
|
|
162
|
+
url = self._urls[slot_id % len(self._urls)]
|
|
163
|
+
else:
|
|
164
|
+
url = self._urls[self._next % len(self._urls)]
|
|
165
|
+
self._next += 1
|
|
166
|
+
return ProxySession(url=url, metadata={"sticky": sticky})
|