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/store.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fleet.core.backend import Backend
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class WorkerRec:
|
|
12
|
+
worker_id: str
|
|
13
|
+
automation_type: str
|
|
14
|
+
hardware: dict[str, Any]
|
|
15
|
+
config: dict[str, Any]
|
|
16
|
+
config_gen: int
|
|
17
|
+
state: str
|
|
18
|
+
last_seen: float
|
|
19
|
+
last_error: str | None
|
|
20
|
+
stats: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> dict[str, Any]:
|
|
23
|
+
return {
|
|
24
|
+
"worker_id": self.worker_id,
|
|
25
|
+
"automation_type": self.automation_type,
|
|
26
|
+
"hardware": self.hardware,
|
|
27
|
+
"config": self.config,
|
|
28
|
+
"config_gen": self.config_gen,
|
|
29
|
+
"state": self.state,
|
|
30
|
+
"last_seen": self.last_seen,
|
|
31
|
+
"last_error": self.last_error,
|
|
32
|
+
"current_stats": self.stats,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Store:
|
|
37
|
+
"""Master-side facade over Backend that produces WorkerRec objects."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, backend: Backend) -> None:
|
|
40
|
+
self._backend = backend
|
|
41
|
+
|
|
42
|
+
async def upsert_register(
|
|
43
|
+
self, automation_type: str, worker_id: str, hardware: dict[str, Any]
|
|
44
|
+
) -> None:
|
|
45
|
+
await self._backend.worker_register(automation_type, worker_id, hardware)
|
|
46
|
+
|
|
47
|
+
async def get_config(self, automation_type: str, worker_id: str) -> tuple[dict[str, Any], int]:
|
|
48
|
+
return await self._backend.worker_get_config(automation_type, worker_id)
|
|
49
|
+
|
|
50
|
+
async def patch_config(
|
|
51
|
+
self, automation_type: str, worker_id: str, partial: dict[str, Any]
|
|
52
|
+
) -> tuple[dict[str, Any], int]:
|
|
53
|
+
current, _ = await self._backend.worker_get_config(automation_type, worker_id)
|
|
54
|
+
merged = {**current, **partial}
|
|
55
|
+
return await self._backend.worker_set_config(automation_type, worker_id, merged)
|
|
56
|
+
|
|
57
|
+
async def replace_config(
|
|
58
|
+
self, automation_type: str, worker_id: str, full: dict[str, Any]
|
|
59
|
+
) -> tuple[dict[str, Any], int]:
|
|
60
|
+
return await self._backend.worker_set_config(automation_type, worker_id, full)
|
|
61
|
+
|
|
62
|
+
async def report_state(
|
|
63
|
+
self,
|
|
64
|
+
automation_type: str,
|
|
65
|
+
worker_id: str,
|
|
66
|
+
state: str,
|
|
67
|
+
last_error: str | None,
|
|
68
|
+
) -> None:
|
|
69
|
+
await self._backend.worker_set_state(
|
|
70
|
+
automation_type, worker_id, state, last_error, last_seen=time.time()
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
async def report_stats(
|
|
74
|
+
self, automation_type: str, worker_id: str, stats: dict[str, Any]
|
|
75
|
+
) -> None:
|
|
76
|
+
await self._backend.worker_set_stats(automation_type, worker_id, stats)
|
|
77
|
+
|
|
78
|
+
async def get_worker(self, automation_type: str, worker_id: str) -> WorkerRec | None:
|
|
79
|
+
raw = await self._backend.worker_get(automation_type, worker_id)
|
|
80
|
+
if raw is None:
|
|
81
|
+
return None
|
|
82
|
+
return WorkerRec(
|
|
83
|
+
worker_id=worker_id,
|
|
84
|
+
automation_type=automation_type,
|
|
85
|
+
hardware=raw["hardware"],
|
|
86
|
+
config=raw["config"],
|
|
87
|
+
config_gen=raw["config_gen"],
|
|
88
|
+
state=raw["state"],
|
|
89
|
+
last_seen=raw["last_seen"],
|
|
90
|
+
last_error=raw["last_error"],
|
|
91
|
+
stats=raw["stats"],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
async def list_workers(self, automation_type: str | None = None) -> list[WorkerRec]:
|
|
95
|
+
pairs = await self._backend.worker_list(automation_type)
|
|
96
|
+
out: list[WorkerRec] = []
|
|
97
|
+
for at, wid in pairs:
|
|
98
|
+
rec = await self.get_worker(at, wid)
|
|
99
|
+
if rec is not None:
|
|
100
|
+
out.append(rec)
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
async def remove_worker(self, automation_type: str, worker_id: str) -> bool:
|
|
104
|
+
return await self._backend.worker_remove(automation_type, worker_id)
|
fleet/master/__init__.py
ADDED
fleet/master/api.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
9
|
+
from pydantic import BaseModel, ValidationError
|
|
10
|
+
|
|
11
|
+
from fleet.core.automation import BaseAutomation, catalog_doc, get_registry
|
|
12
|
+
from fleet.core.backend import Backend
|
|
13
|
+
from fleet.core.otel import span
|
|
14
|
+
from fleet.core.protocol import ConfigChanged, Drain
|
|
15
|
+
from fleet.core.store import Store
|
|
16
|
+
from fleet.master.auth import require_admin, require_scope, require_worker
|
|
17
|
+
from fleet.master.broadcaster import Broadcaster
|
|
18
|
+
from fleet.master.ratelimit import RateLimiter, make_token_rate_limit
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PopRequest(BaseModel):
|
|
24
|
+
n: int = 1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SubmitRequest(BaseModel):
|
|
28
|
+
payload: dict[str, Any]
|
|
29
|
+
task_id: str | None = None
|
|
30
|
+
max_attempts: int | None = None
|
|
31
|
+
priority: int = 0
|
|
32
|
+
"""Higher = more urgent. >0 routes to the high-priority bucket;
|
|
33
|
+
<0 to the low-priority bucket; 0 (default) to the normal bucket."""
|
|
34
|
+
ttl_seconds: int | None = None
|
|
35
|
+
"""If set, the task is silently dropped on reserve when older than
|
|
36
|
+
this many seconds. Useful for "act on now or never" events."""
|
|
37
|
+
idempotency_key: str | None = None
|
|
38
|
+
"""If set, a second submission with the same (automation_type, key) within
|
|
39
|
+
the idempotency window returns the original task_id without enqueueing."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _decode_envelopes(raw_list: list[bytes]) -> list[dict[str, Any]]:
|
|
43
|
+
out: list[dict[str, Any]] = []
|
|
44
|
+
for raw in raw_list:
|
|
45
|
+
try:
|
|
46
|
+
out.append(json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw))
|
|
47
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
48
|
+
continue
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def make_api_router(
|
|
53
|
+
store: Store, broadcaster: Broadcaster, backend: Backend
|
|
54
|
+
) -> APIRouter:
|
|
55
|
+
router = APIRouter()
|
|
56
|
+
registry: dict[str, type[BaseAutomation]] = get_registry()
|
|
57
|
+
import os as _os
|
|
58
|
+
submit_rps = float(_os.environ.get("FLEET_SUBMIT_RPS_PER_TOKEN", "0"))
|
|
59
|
+
submit_limiter = RateLimiter(submit_rps)
|
|
60
|
+
submit_rate_dep = make_token_rate_limit(submit_limiter)
|
|
61
|
+
idempotency_ttl = int(_os.environ.get("FLEET_IDEMPOTENCY_TTL_SECONDS", "86400"))
|
|
62
|
+
|
|
63
|
+
@router.get("/api/v1/automations", dependencies=[Depends(require_admin)])
|
|
64
|
+
async def list_automations() -> list[dict[str, Any]]:
|
|
65
|
+
return [
|
|
66
|
+
{
|
|
67
|
+
"type": name,
|
|
68
|
+
"class": cls.__name__,
|
|
69
|
+
"config_schema": cls.Config.model_json_schema(),
|
|
70
|
+
"kind": cls.__mro__[1].__name__,
|
|
71
|
+
}
|
|
72
|
+
for name, cls in registry.items()
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
@router.get(
|
|
76
|
+
"/api/v1/automations/{automation_type}/workers",
|
|
77
|
+
dependencies=[Depends(require_scope)],
|
|
78
|
+
)
|
|
79
|
+
async def list_workers(automation_type: str) -> list[dict[str, Any]]:
|
|
80
|
+
_require_type(automation_type, registry)
|
|
81
|
+
recs = await store.list_workers(automation_type)
|
|
82
|
+
return [r.to_dict() for r in recs]
|
|
83
|
+
|
|
84
|
+
@router.get(
|
|
85
|
+
"/api/v1/automations/{automation_type}/workers/{worker_id}",
|
|
86
|
+
dependencies=[Depends(require_scope)],
|
|
87
|
+
)
|
|
88
|
+
async def get_worker(automation_type: str, worker_id: str) -> dict[str, Any]:
|
|
89
|
+
_require_type(automation_type, registry)
|
|
90
|
+
rec = await store.get_worker(automation_type, worker_id)
|
|
91
|
+
if rec is None:
|
|
92
|
+
raise HTTPException(404, "worker not found")
|
|
93
|
+
return rec.to_dict()
|
|
94
|
+
|
|
95
|
+
@router.patch(
|
|
96
|
+
"/api/v1/automations/{automation_type}/workers/{worker_id}/config",
|
|
97
|
+
dependencies=[Depends(require_scope)],
|
|
98
|
+
)
|
|
99
|
+
async def patch_config(
|
|
100
|
+
automation_type: str, worker_id: str, partial: dict[str, Any]
|
|
101
|
+
) -> dict[str, Any]:
|
|
102
|
+
cls = _require_type(automation_type, registry)
|
|
103
|
+
current, _ = await store.get_config(automation_type, worker_id)
|
|
104
|
+
merged = {**current, **partial}
|
|
105
|
+
try:
|
|
106
|
+
cls.Config.model_validate(merged)
|
|
107
|
+
except ValidationError as e:
|
|
108
|
+
raise HTTPException(422, f"invalid config: {e}") from e
|
|
109
|
+
cfg, gen = await store.replace_config(automation_type, worker_id, merged)
|
|
110
|
+
await broadcaster.publish(
|
|
111
|
+
automation_type,
|
|
112
|
+
worker_id,
|
|
113
|
+
ConfigChanged(config_gen=gen).model_dump(),
|
|
114
|
+
)
|
|
115
|
+
return {"config": cfg, "config_gen": gen}
|
|
116
|
+
|
|
117
|
+
@router.get(
|
|
118
|
+
"/api/v1/automations/{automation_type}/output/length",
|
|
119
|
+
dependencies=[Depends(require_scope)],
|
|
120
|
+
)
|
|
121
|
+
async def stream_len(automation_type: str) -> dict[str, int]:
|
|
122
|
+
_require_type(automation_type, registry)
|
|
123
|
+
return {"length": await backend.stream_length(automation_type)}
|
|
124
|
+
|
|
125
|
+
@router.get(
|
|
126
|
+
"/api/v1/automations/{automation_type}/queue",
|
|
127
|
+
dependencies=[Depends(require_scope)],
|
|
128
|
+
)
|
|
129
|
+
async def queue_inspect(automation_type: str, n: int = 20) -> dict[str, Any]:
|
|
130
|
+
_require_type(automation_type, registry)
|
|
131
|
+
items = _decode_envelopes(await backend.queue_peek(automation_type, n))
|
|
132
|
+
length = await backend.queue_size(automation_type)
|
|
133
|
+
return {"length": length, "items": items}
|
|
134
|
+
|
|
135
|
+
@router.get(
|
|
136
|
+
"/api/v1/pools/{pool_name}",
|
|
137
|
+
dependencies=[Depends(require_admin)],
|
|
138
|
+
)
|
|
139
|
+
async def pool_inspect(pool_name: str, n: int = 20) -> dict[str, Any]:
|
|
140
|
+
raws = await backend.pool_list(pool_name)
|
|
141
|
+
size = await backend.pool_size(pool_name)
|
|
142
|
+
items: list[dict[str, Any]] = []
|
|
143
|
+
for raw in raws[:n]:
|
|
144
|
+
try:
|
|
145
|
+
payload = json.loads(raw.payload.decode("utf-8"))
|
|
146
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
147
|
+
payload = None
|
|
148
|
+
items.append({
|
|
149
|
+
"id": raw.id,
|
|
150
|
+
"tags": raw.tags,
|
|
151
|
+
"payload": payload,
|
|
152
|
+
"expires_at": raw.expires_at,
|
|
153
|
+
"in_use_until": raw.in_use_until,
|
|
154
|
+
})
|
|
155
|
+
return {"size": size, "items": items}
|
|
156
|
+
|
|
157
|
+
@router.get(
|
|
158
|
+
"/api/v1/automations/{automation_type}/output/peek",
|
|
159
|
+
dependencies=[Depends(require_scope)],
|
|
160
|
+
)
|
|
161
|
+
async def stream_pk(automation_type: str, n: int = 10) -> list[dict[str, Any]]:
|
|
162
|
+
_require_type(automation_type, registry)
|
|
163
|
+
return _decode_envelopes(await backend.stream_peek(automation_type, n))
|
|
164
|
+
|
|
165
|
+
@router.post(
|
|
166
|
+
"/api/v1/automations/{automation_type}/output/pop",
|
|
167
|
+
dependencies=[Depends(require_scope)],
|
|
168
|
+
)
|
|
169
|
+
async def stream_pp(automation_type: str, req: PopRequest) -> list[dict[str, Any]]:
|
|
170
|
+
_require_type(automation_type, registry)
|
|
171
|
+
return _decode_envelopes(await backend.stream_pop(automation_type, req.n))
|
|
172
|
+
|
|
173
|
+
@router.get("/api/v1/catalog", dependencies=[Depends(require_admin)])
|
|
174
|
+
async def get_catalog() -> dict[str, Any]:
|
|
175
|
+
return {name: catalog_doc(cls) for name, cls in registry.items()}
|
|
176
|
+
|
|
177
|
+
@router.get(
|
|
178
|
+
"/api/v1/catalog/{automation_type}",
|
|
179
|
+
dependencies=[Depends(require_scope)],
|
|
180
|
+
)
|
|
181
|
+
async def get_catalog_one(automation_type: str) -> dict[str, Any]:
|
|
182
|
+
cls = _require_type(automation_type, registry)
|
|
183
|
+
return catalog_doc(cls)
|
|
184
|
+
|
|
185
|
+
@router.post(
|
|
186
|
+
"/api/v1/automations/{automation_type}/tasks",
|
|
187
|
+
dependencies=[Depends(require_scope), Depends(submit_rate_dep)],
|
|
188
|
+
)
|
|
189
|
+
async def submit_task(automation_type: str, req: SubmitRequest) -> dict[str, Any]:
|
|
190
|
+
cls = _require_type(automation_type, registry)
|
|
191
|
+
if cls.TaskPayload is None:
|
|
192
|
+
raise HTTPException(
|
|
193
|
+
400,
|
|
194
|
+
f"automation '{automation_type}' did not declare TaskPayload; "
|
|
195
|
+
"only BatchAutomations with a declared task schema accept submissions",
|
|
196
|
+
)
|
|
197
|
+
try:
|
|
198
|
+
cls.TaskPayload.model_validate(req.payload)
|
|
199
|
+
except ValidationError as e:
|
|
200
|
+
raise HTTPException(422, f"invalid task payload: {e}") from e
|
|
201
|
+
tid = req.task_id or uuid.uuid4().hex
|
|
202
|
+
if req.idempotency_key:
|
|
203
|
+
ns = f"idem:{automation_type}"
|
|
204
|
+
claimed = await backend.kv_setnx(
|
|
205
|
+
ns, req.idempotency_key, tid.encode("utf-8"), idempotency_ttl,
|
|
206
|
+
)
|
|
207
|
+
if not claimed:
|
|
208
|
+
existing = await backend.kv_get(ns, req.idempotency_key)
|
|
209
|
+
if existing is not None:
|
|
210
|
+
return {
|
|
211
|
+
"task_id": existing.decode("utf-8"),
|
|
212
|
+
"queued": False,
|
|
213
|
+
"duplicate": True,
|
|
214
|
+
}
|
|
215
|
+
import time as _time
|
|
216
|
+
envelope: dict[str, Any] = {
|
|
217
|
+
"id": tid,
|
|
218
|
+
"payload": req.payload,
|
|
219
|
+
"attempt": 0,
|
|
220
|
+
"submitted_at": _time.time(),
|
|
221
|
+
}
|
|
222
|
+
if req.max_attempts is not None:
|
|
223
|
+
envelope["max_attempts"] = req.max_attempts
|
|
224
|
+
if req.ttl_seconds is not None:
|
|
225
|
+
envelope["ttl_seconds"] = req.ttl_seconds
|
|
226
|
+
body = json.dumps(envelope).encode("utf-8")
|
|
227
|
+
with span(
|
|
228
|
+
"api.submit_task",
|
|
229
|
+
automation_type=automation_type,
|
|
230
|
+
task_id=tid,
|
|
231
|
+
priority=req.priority,
|
|
232
|
+
):
|
|
233
|
+
await backend.queue_push(automation_type, body, priority=req.priority)
|
|
234
|
+
return {"task_id": tid, "queued": True, "priority": req.priority}
|
|
235
|
+
|
|
236
|
+
@router.get(
|
|
237
|
+
"/api/v1/automations/{automation_type}/dead",
|
|
238
|
+
dependencies=[Depends(require_scope)],
|
|
239
|
+
)
|
|
240
|
+
async def dlq_peek(automation_type: str, n: int = 50) -> dict[str, Any]:
|
|
241
|
+
_require_type(automation_type, registry)
|
|
242
|
+
items = _decode_envelopes(await backend.dlq_peek(automation_type, n))
|
|
243
|
+
length = await backend.dlq_length(automation_type)
|
|
244
|
+
return {"length": length, "items": items}
|
|
245
|
+
|
|
246
|
+
@router.post(
|
|
247
|
+
"/api/v1/automations/{automation_type}/dead/replay",
|
|
248
|
+
dependencies=[Depends(require_scope)],
|
|
249
|
+
)
|
|
250
|
+
async def dlq_replay(automation_type: str, req: PopRequest) -> dict[str, int]:
|
|
251
|
+
_require_type(automation_type, registry)
|
|
252
|
+
popped = await backend.dlq_pop(automation_type, req.n)
|
|
253
|
+
for body in popped:
|
|
254
|
+
try:
|
|
255
|
+
env = json.loads(body.decode("utf-8") if isinstance(body, bytes) else body)
|
|
256
|
+
env["attempt"] = 0 # reset attempts on replay
|
|
257
|
+
body = json.dumps(env).encode("utf-8")
|
|
258
|
+
except Exception:
|
|
259
|
+
pass
|
|
260
|
+
await backend.queue_push(automation_type, body)
|
|
261
|
+
return {"replayed": len(popped)}
|
|
262
|
+
|
|
263
|
+
@router.delete(
|
|
264
|
+
"/api/v1/automations/{automation_type}/dead",
|
|
265
|
+
dependencies=[Depends(require_scope)],
|
|
266
|
+
)
|
|
267
|
+
async def dlq_drain(automation_type: str) -> dict[str, int]:
|
|
268
|
+
_require_type(automation_type, registry)
|
|
269
|
+
return {"cleared": await backend.dlq_clear(automation_type)}
|
|
270
|
+
|
|
271
|
+
@router.post(
|
|
272
|
+
"/api/v1/automations/{automation_type}/workers/{worker_id}/drain",
|
|
273
|
+
dependencies=[Depends(require_scope)],
|
|
274
|
+
)
|
|
275
|
+
async def drain_worker(automation_type: str, worker_id: str) -> dict[str, Any]:
|
|
276
|
+
_require_type(automation_type, registry)
|
|
277
|
+
rec = await store.get_worker(automation_type, worker_id)
|
|
278
|
+
if rec is None:
|
|
279
|
+
raise HTTPException(404, "worker not found")
|
|
280
|
+
await broadcaster.publish(
|
|
281
|
+
automation_type, worker_id, Drain().model_dump()
|
|
282
|
+
)
|
|
283
|
+
return {"worker_id": worker_id, "drain": "requested"}
|
|
284
|
+
|
|
285
|
+
@router.delete(
|
|
286
|
+
"/api/v1/automations/{automation_type}/workers/{worker_id}",
|
|
287
|
+
dependencies=[Depends(require_scope)],
|
|
288
|
+
)
|
|
289
|
+
async def delete_worker(automation_type: str, worker_id: str) -> dict[str, Any]:
|
|
290
|
+
_require_type(automation_type, registry)
|
|
291
|
+
removed = await store.remove_worker(automation_type, worker_id)
|
|
292
|
+
if not removed:
|
|
293
|
+
raise HTTPException(404, "worker not found")
|
|
294
|
+
return {"worker_id": worker_id, "removed": True}
|
|
295
|
+
|
|
296
|
+
@router.get(
|
|
297
|
+
"/api/v1/automations/{automation_type}/workers/{worker_id}/stats",
|
|
298
|
+
dependencies=[Depends(require_scope)],
|
|
299
|
+
)
|
|
300
|
+
async def worker_stats_history(
|
|
301
|
+
automation_type: str, worker_id: str, since: float = 0, limit: int = 240
|
|
302
|
+
) -> list[dict[str, Any]]:
|
|
303
|
+
_require_type(automation_type, registry)
|
|
304
|
+
return await backend.worker_stats_history(
|
|
305
|
+
automation_type, worker_id, since=since, limit=limit,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
@router.get(
|
|
309
|
+
"/api/v1/automations/{automation_type}/workers/{worker_id}/config",
|
|
310
|
+
dependencies=[Depends(require_worker)],
|
|
311
|
+
)
|
|
312
|
+
async def worker_poll(automation_type: str, worker_id: str) -> dict[str, Any]:
|
|
313
|
+
_require_type(automation_type, registry)
|
|
314
|
+
cfg, gen = await store.get_config(automation_type, worker_id)
|
|
315
|
+
return {"config": cfg, "config_gen": gen}
|
|
316
|
+
|
|
317
|
+
return router
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _require_type(automation_type: str, registry: dict[str, type[BaseAutomation]]) -> type[BaseAutomation]:
|
|
321
|
+
cls = registry.get(automation_type)
|
|
322
|
+
if cls is None:
|
|
323
|
+
raise HTTPException(404, f"unknown automation type: {automation_type}")
|
|
324
|
+
return cls
|
fleet/master/app.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
|
|
10
|
+
from fleet.core.automation import catalog_doc, get_registry, load_entry_points
|
|
11
|
+
from fleet.core.backend import backend_from_url
|
|
12
|
+
from fleet.core.proxy import load_provider_entry_points
|
|
13
|
+
from fleet.core.store import Store
|
|
14
|
+
from fleet.master.api import make_api_router
|
|
15
|
+
from fleet.master.broadcaster import Broadcaster
|
|
16
|
+
from fleet.master.dashboard import make_dashboard_router
|
|
17
|
+
from fleet.master.metrics_route import make_metrics_router
|
|
18
|
+
from fleet.master.ws_router import make_ws_router
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_app(backend_url: str | None = None) -> FastAPI:
|
|
24
|
+
# Backwards-compatible: callers passing redis://... still work because
|
|
25
|
+
# backend_from_url handles redis:// natively. New callers can pass
|
|
26
|
+
# memory:// or any registered scheme.
|
|
27
|
+
backend_url = (
|
|
28
|
+
backend_url
|
|
29
|
+
or os.environ.get("FLEET_BACKEND_URL")
|
|
30
|
+
or os.environ.get("REDIS_URL", "redis://localhost:6379/0")
|
|
31
|
+
)
|
|
32
|
+
registry = load_entry_points()
|
|
33
|
+
providers = load_provider_entry_points()
|
|
34
|
+
logger.info("loaded automations: %s", list(registry.keys()))
|
|
35
|
+
logger.info("loaded proxy providers: %s", list(providers.keys()))
|
|
36
|
+
|
|
37
|
+
backend = backend_from_url(backend_url)
|
|
38
|
+
store = Store(backend)
|
|
39
|
+
broadcaster = Broadcaster()
|
|
40
|
+
|
|
41
|
+
app = FastAPI(title="fleet")
|
|
42
|
+
app.state.store = store
|
|
43
|
+
app.state.backend = backend
|
|
44
|
+
app.state.broadcaster = broadcaster
|
|
45
|
+
|
|
46
|
+
# publish the catalog: schemas for every installed automation. dashboards
|
|
47
|
+
# and operators read this; consumers don't need it for typing because they
|
|
48
|
+
# import the same contract refs as the producer.
|
|
49
|
+
@app.on_event("startup")
|
|
50
|
+
async def _publish_catalog() -> None:
|
|
51
|
+
for name, cls in get_registry().items():
|
|
52
|
+
try:
|
|
53
|
+
doc = json.dumps(catalog_doc(cls)).encode("utf-8")
|
|
54
|
+
await backend.catalog_set(name, doc)
|
|
55
|
+
except Exception:
|
|
56
|
+
logger.exception("failed to publish catalog entry for %s", name)
|
|
57
|
+
|
|
58
|
+
sweeper_task: "asyncio.Task | None" = None
|
|
59
|
+
worker_ttl_seconds = int(os.environ.get("FLEET_WORKER_IDLE_TTL_SECONDS", "0"))
|
|
60
|
+
|
|
61
|
+
@app.on_event("startup")
|
|
62
|
+
async def _start_sweeper() -> None:
|
|
63
|
+
nonlocal sweeper_task
|
|
64
|
+
import time as _time
|
|
65
|
+
|
|
66
|
+
async def _loop() -> None:
|
|
67
|
+
while True:
|
|
68
|
+
try:
|
|
69
|
+
for name in get_registry():
|
|
70
|
+
moved = await backend.queue_sweep_expired(name)
|
|
71
|
+
if moved:
|
|
72
|
+
logger.info("swept %d expired reservation(s) from %s", moved, name)
|
|
73
|
+
if worker_ttl_seconds > 0:
|
|
74
|
+
cutoff = _time.time() - worker_ttl_seconds
|
|
75
|
+
for at in get_registry():
|
|
76
|
+
for rec in await store.list_workers(at):
|
|
77
|
+
if rec.last_seen and rec.last_seen < cutoff:
|
|
78
|
+
if await store.remove_worker(at, rec.worker_id):
|
|
79
|
+
logger.info(
|
|
80
|
+
"evicted idle worker %s/%s (last_seen %.0fs ago)",
|
|
81
|
+
at, rec.worker_id, _time.time() - rec.last_seen,
|
|
82
|
+
)
|
|
83
|
+
except asyncio.CancelledError:
|
|
84
|
+
raise
|
|
85
|
+
except Exception:
|
|
86
|
+
logger.exception("queue sweep failed")
|
|
87
|
+
await asyncio.sleep(10)
|
|
88
|
+
|
|
89
|
+
sweeper_task = asyncio.create_task(_loop(), name="queue-sweeper")
|
|
90
|
+
|
|
91
|
+
@app.on_event("shutdown")
|
|
92
|
+
async def _stop_sweeper() -> None:
|
|
93
|
+
if sweeper_task is not None and not sweeper_task.done():
|
|
94
|
+
sweeper_task.cancel()
|
|
95
|
+
|
|
96
|
+
app.include_router(make_api_router(store, broadcaster, backend))
|
|
97
|
+
app.include_router(make_ws_router(store, broadcaster, backend))
|
|
98
|
+
app.include_router(make_metrics_router(store, backend))
|
|
99
|
+
app.include_router(make_dashboard_router())
|
|
100
|
+
|
|
101
|
+
@app.get("/healthz")
|
|
102
|
+
async def healthz() -> dict[str, str]:
|
|
103
|
+
return {"status": "ok"}
|
|
104
|
+
|
|
105
|
+
return app
|
fleet/master/auth.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
|
|
8
|
+
from fastapi import Header, HTTPException
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _expected(env_name: str, fallback: str | None) -> str:
|
|
14
|
+
val = os.environ.get(env_name) or fallback
|
|
15
|
+
if not val:
|
|
16
|
+
val = secrets.token_hex(16)
|
|
17
|
+
os.environ[env_name] = val
|
|
18
|
+
print(f"[fleet] generated {env_name}={val}")
|
|
19
|
+
return val
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def admin_token() -> str:
|
|
23
|
+
return _expected("FLEET_ADMIN_TOKEN", None)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def worker_token() -> str:
|
|
27
|
+
"""Primary worker token. Kept for the WS-token check path that needs
|
|
28
|
+
a single canonical value (it always falls back to the first accepted
|
|
29
|
+
one). For request-time auth, use `worker_tokens()` which also accepts
|
|
30
|
+
rotation peers from FLEET_WORKER_TOKENS."""
|
|
31
|
+
return _expected("FLEET_WORKER_TOKEN", None)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def worker_tokens() -> tuple[str, ...]:
|
|
35
|
+
"""All currently-valid worker tokens. The primary token from
|
|
36
|
+
FLEET_WORKER_TOKEN is always included; additional rotation tokens
|
|
37
|
+
can be provided via FLEET_WORKER_TOKENS as a comma-separated string
|
|
38
|
+
or a JSON list. During a rotation, set the new token first, roll
|
|
39
|
+
workers across to it, then drop the old one."""
|
|
40
|
+
primary = worker_token()
|
|
41
|
+
extras: list[str] = []
|
|
42
|
+
raw = os.environ.get("FLEET_WORKER_TOKENS")
|
|
43
|
+
if raw:
|
|
44
|
+
raw = raw.strip()
|
|
45
|
+
if raw.startswith("["):
|
|
46
|
+
try:
|
|
47
|
+
parsed = json.loads(raw)
|
|
48
|
+
if isinstance(parsed, list):
|
|
49
|
+
extras = [t for t in parsed if isinstance(t, str) and t]
|
|
50
|
+
except json.JSONDecodeError:
|
|
51
|
+
logger.warning("FLEET_WORKER_TOKENS is not valid JSON; ignoring")
|
|
52
|
+
else:
|
|
53
|
+
extras = [t.strip() for t in raw.split(",") if t.strip()]
|
|
54
|
+
seen: set[str] = set()
|
|
55
|
+
out: list[str] = []
|
|
56
|
+
for t in (primary, *extras):
|
|
57
|
+
if t not in seen:
|
|
58
|
+
seen.add(t)
|
|
59
|
+
out.append(t)
|
|
60
|
+
return tuple(out)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _matches_any(token: str, candidates: tuple[str, ...]) -> bool:
|
|
64
|
+
matched = False
|
|
65
|
+
for c in candidates:
|
|
66
|
+
if secrets.compare_digest(token, c):
|
|
67
|
+
matched = True
|
|
68
|
+
return matched
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _token_scopes() -> dict[str, set[str]]:
|
|
72
|
+
"""Parse FLEET_TOKEN_SCOPES as a JSON map of token -> list of automation types.
|
|
73
|
+
|
|
74
|
+
The admin token implicitly has the wildcard scope `*`. Bad JSON is logged
|
|
75
|
+
and ignored (the admin token still works).
|
|
76
|
+
"""
|
|
77
|
+
raw = os.environ.get("FLEET_TOKEN_SCOPES")
|
|
78
|
+
out: dict[str, set[str]] = {admin_token(): {"*"}}
|
|
79
|
+
if not raw:
|
|
80
|
+
return out
|
|
81
|
+
try:
|
|
82
|
+
parsed = json.loads(raw)
|
|
83
|
+
except json.JSONDecodeError:
|
|
84
|
+
logger.warning("FLEET_TOKEN_SCOPES is not valid JSON; ignoring")
|
|
85
|
+
return out
|
|
86
|
+
if not isinstance(parsed, dict):
|
|
87
|
+
logger.warning("FLEET_TOKEN_SCOPES must be a JSON object {token: [types]}")
|
|
88
|
+
return out
|
|
89
|
+
for tok, scopes in parsed.items():
|
|
90
|
+
if not isinstance(tok, str) or not isinstance(scopes, list):
|
|
91
|
+
continue
|
|
92
|
+
out[tok] = {s for s in scopes if isinstance(s, str)}
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _bearer(authorization: str | None) -> str:
|
|
97
|
+
if not authorization:
|
|
98
|
+
raise HTTPException(401, "missing Authorization header")
|
|
99
|
+
if not authorization.lower().startswith("bearer "):
|
|
100
|
+
raise HTTPException(401, "Authorization must be Bearer <token>")
|
|
101
|
+
return authorization.split(" ", 1)[1].strip()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def require_admin(authorization: str | None = Header(default=None)) -> None:
|
|
105
|
+
token = _bearer(authorization)
|
|
106
|
+
if not secrets.compare_digest(token, admin_token()):
|
|
107
|
+
raise HTTPException(403, "invalid token")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def require_worker(authorization: str | None = Header(default=None)) -> None:
|
|
111
|
+
token = _bearer(authorization)
|
|
112
|
+
if not _matches_any(token, worker_tokens()):
|
|
113
|
+
raise HTTPException(403, "invalid token")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def require_scope(
|
|
117
|
+
automation_type: str,
|
|
118
|
+
authorization: str | None = Header(default=None),
|
|
119
|
+
) -> None:
|
|
120
|
+
token = _bearer(authorization)
|
|
121
|
+
scopes_by_token = _token_scopes()
|
|
122
|
+
# find the token by constant-time compare against each known one
|
|
123
|
+
granted: set[str] | None = None
|
|
124
|
+
for known, scopes in scopes_by_token.items():
|
|
125
|
+
if secrets.compare_digest(token, known):
|
|
126
|
+
granted = scopes
|
|
127
|
+
break
|
|
128
|
+
if granted is None:
|
|
129
|
+
raise HTTPException(403, "invalid token")
|
|
130
|
+
if "*" in granted or automation_type in granted:
|
|
131
|
+
return
|
|
132
|
+
raise HTTPException(403, f"token has no scope for '{automation_type}'")
|