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.
Files changed (85) hide show
  1. fleet/__init__.py +1 -0
  2. fleet/cli.py +290 -0
  3. fleet/core/__init__.py +69 -0
  4. fleet/core/automation.py +125 -0
  5. fleet/core/backend.py +736 -0
  6. fleet/core/config.py +38 -0
  7. fleet/core/context.py +102 -0
  8. fleet/core/contract.py +87 -0
  9. fleet/core/country_presets.py +50 -0
  10. fleet/core/events.py +55 -0
  11. fleet/core/logging.py +97 -0
  12. fleet/core/memory_backend.py +492 -0
  13. fleet/core/metrics.py +61 -0
  14. fleet/core/otel.py +97 -0
  15. fleet/core/primitives.py +310 -0
  16. fleet/core/protocol.py +171 -0
  17. fleet/core/proxy.py +166 -0
  18. fleet/core/reconcile.py +75 -0
  19. fleet/core/sqlite_backend.py +1117 -0
  20. fleet/core/store.py +104 -0
  21. fleet/master/__init__.py +3 -0
  22. fleet/master/api.py +324 -0
  23. fleet/master/app.py +105 -0
  24. fleet/master/auth.py +132 -0
  25. fleet/master/broadcaster.py +37 -0
  26. fleet/master/dashboard/__init__.py +4 -0
  27. fleet/master/dashboard/router.py +36 -0
  28. fleet/master/dashboard/static/style.css +97 -0
  29. fleet/master/dashboard/templates/index.html +372 -0
  30. fleet/master/metrics_route.py +141 -0
  31. fleet/master/ratelimit.py +55 -0
  32. fleet/master/ws_router.py +142 -0
  33. fleet/worker/__init__.py +3 -0
  34. fleet/worker/agent.py +173 -0
  35. fleet/worker/reconcile_loop.py +246 -0
  36. fleet/worker/slot_runner.py +256 -0
  37. fleet/worker/ws_client.py +164 -0
  38. fleet_browser/__init__.py +21 -0
  39. fleet_browser/browser.py +277 -0
  40. fleet_browser/cert.py +68 -0
  41. fleet_browser/fingerprint.py +327 -0
  42. fleet_browser/humanizer.py +157 -0
  43. fleet_browser/pool.py +241 -0
  44. fleet_browser/proxy_extension.py +122 -0
  45. fleet_browser/solver.py +51 -0
  46. fleet_browser/stealth.py +80 -0
  47. fleet_cloudflare/__init__.py +22 -0
  48. fleet_cloudflare/bypasser.py +168 -0
  49. fleet_cloudflare/harvest.py +266 -0
  50. fleet_cloudflare/replay.py +82 -0
  51. fleet_cloudflare/solver.py +28 -0
  52. fleet_content/__init__.py +24 -0
  53. fleet_content/automation.py +43 -0
  54. fleet_content/contracts.py +76 -0
  55. fleet_detect/__init__.py +26 -0
  56. fleet_detect/contracts.py +67 -0
  57. fleet_detect/detect.py +126 -0
  58. fleet_framework-0.1.0.dist-info/METADATA +160 -0
  59. fleet_framework-0.1.0.dist-info/RECORD +85 -0
  60. fleet_framework-0.1.0.dist-info/WHEEL +5 -0
  61. fleet_framework-0.1.0.dist-info/entry_points.txt +9 -0
  62. fleet_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
  63. fleet_framework-0.1.0.dist-info/top_level.txt +14 -0
  64. fleet_headers/__init__.py +28 -0
  65. fleet_headers/profiles.py +131 -0
  66. fleet_jobs/__init__.py +28 -0
  67. fleet_jobs/automation.py +34 -0
  68. fleet_jobs/contracts.py +143 -0
  69. fleet_marketplace/__init__.py +33 -0
  70. fleet_marketplace/automation.py +32 -0
  71. fleet_marketplace/contracts.py +151 -0
  72. fleet_news/__init__.py +21 -0
  73. fleet_news/automation.py +51 -0
  74. fleet_news/contracts.py +59 -0
  75. fleet_place/__init__.py +33 -0
  76. fleet_place/automation.py +37 -0
  77. fleet_place/contracts.py +156 -0
  78. fleet_provider_dataimpulse/__init__.py +82 -0
  79. fleet_provider_evomi/__init__.py +76 -0
  80. fleet_serp/__init__.py +30 -0
  81. fleet_serp/automation.py +47 -0
  82. fleet_serp/contracts.py +100 -0
  83. fleet_social/__init__.py +34 -0
  84. fleet_social/automation.py +44 -0
  85. 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)
@@ -0,0 +1,3 @@
1
+ from fleet.master.app import create_app
2
+
3
+ __all__ = ["create_app"]
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}'")