cloudwire 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.
@@ -0,0 +1,368 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime, timedelta, timezone
7
+ from threading import Lock
8
+ from typing import Any, Callable, Dict, List, Optional
9
+ from uuid import uuid4
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ from .graph_store import GraphStore
14
+ from .models import JobStatus, ScanMode
15
+
16
+ _MAX_RETAINED_TERMINAL_JOBS = 50
17
+
18
+
19
+ def _utc_now_iso() -> str:
20
+ return datetime.now(timezone.utc).isoformat()
21
+
22
+
23
+ def _progress_percent(done: int, total: int) -> int:
24
+ if total <= 0:
25
+ return 0
26
+ return max(0, min(100, int((done / total) * 100)))
27
+
28
+
29
+ @dataclass
30
+ class CacheEntry:
31
+ job_id: str
32
+ expires_at: datetime
33
+
34
+
35
+ @dataclass
36
+ class ScanJob:
37
+ id: str
38
+ cache_key: str
39
+ account_id: str
40
+ region: str
41
+ services: List[str]
42
+ mode: ScanMode
43
+ include_iam_inference: bool
44
+ include_resource_describes: bool
45
+ status: JobStatus = "queued"
46
+ progress_percent: int = 0
47
+ current_service: Optional[str] = None
48
+ services_done: int = 0
49
+ services_total: int = 0
50
+ created_at: str = field(default_factory=_utc_now_iso)
51
+ started_at: Optional[str] = None
52
+ finished_at: Optional[str] = None
53
+ error: Optional[str] = None
54
+ cancellation_requested: bool = False
55
+ active_services: List[str] = field(default_factory=list)
56
+ graph_store: GraphStore = field(default_factory=GraphStore)
57
+
58
+
59
+ class ScanJobStore:
60
+ def __init__(self, *, max_workers: int = 4) -> None:
61
+ self._jobs: Dict[str, ScanJob] = {}
62
+ self._in_flight: Dict[str, str] = {}
63
+ self._cache: Dict[str, CacheEntry] = {}
64
+ self._latest_graph_id: Optional[str] = None
65
+ self._lock = Lock()
66
+ self._executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="scan-job")
67
+
68
+ def shutdown(self) -> None:
69
+ self._executor.shutdown(wait=False)
70
+
71
+ def _prune_terminal_jobs_locked(self) -> None:
72
+ terminal_states = {"completed", "failed", "cancelled"}
73
+ terminal = [job for job in self._jobs.values() if job.status in terminal_states]
74
+ if len(terminal) <= _MAX_RETAINED_TERMINAL_JOBS:
75
+ return
76
+ terminal.sort(key=lambda j: j.finished_at or "", reverse=True)
77
+ for job in terminal[_MAX_RETAINED_TERMINAL_JOBS:]:
78
+ if self._latest_graph_id == job.id:
79
+ continue
80
+ cached = self._cache.get(job.cache_key)
81
+ if cached and cached.job_id == job.id:
82
+ continue
83
+ self._jobs.pop(job.id, None)
84
+
85
+ def _prune_expired_cache_locked(self) -> None:
86
+ now = datetime.now(timezone.utc)
87
+ expired_keys = [key for key, value in self._cache.items() if value.expires_at <= now]
88
+ for key in expired_keys:
89
+ self._cache.pop(key, None)
90
+
91
+ def find_reusable_job(self, *, cache_key: str, force_refresh: bool) -> tuple[Optional[str], bool]:
92
+ if force_refresh:
93
+ return None, False
94
+
95
+ with self._lock:
96
+ self._prune_expired_cache_locked()
97
+
98
+ in_flight_id = self._in_flight.get(cache_key)
99
+ if in_flight_id:
100
+ job = self._jobs.get(in_flight_id)
101
+ if job and job.status in {"queued", "running"}:
102
+ return in_flight_id, False
103
+ self._in_flight.pop(cache_key, None)
104
+
105
+ cached = self._cache.get(cache_key)
106
+ if cached and cached.job_id in self._jobs:
107
+ return cached.job_id, True
108
+
109
+ return None, False
110
+
111
+ def create_job(
112
+ self,
113
+ *,
114
+ cache_key: str,
115
+ account_id: str,
116
+ region: str,
117
+ services: List[str],
118
+ mode: ScanMode,
119
+ include_iam_inference: bool,
120
+ include_resource_describes: bool,
121
+ ) -> ScanJob:
122
+ job_id = str(uuid4())
123
+ job = ScanJob(
124
+ id=job_id,
125
+ cache_key=cache_key,
126
+ account_id=account_id,
127
+ region=region,
128
+ services=services,
129
+ mode=mode,
130
+ include_iam_inference=include_iam_inference,
131
+ include_resource_describes=include_resource_describes,
132
+ services_total=len(services),
133
+ )
134
+ with self._lock:
135
+ self._jobs[job_id] = job
136
+ self._in_flight[cache_key] = job_id
137
+ self._prune_terminal_jobs_locked()
138
+ return job
139
+
140
+ def submit_job(self, job_id: str, runner: Callable[[], None]) -> None:
141
+ self._executor.submit(self._run_job_wrapper, job_id, runner)
142
+
143
+ def _run_job_wrapper(self, job_id: str, runner: Callable[[], None]) -> None:
144
+ try:
145
+ runner()
146
+ except Exception as exc:
147
+ logger.exception("Unhandled exception in scan job %s", job_id)
148
+ self.mark_failed(job_id, f"Unhandled scan failure: {type(exc).__name__} - {exc}")
149
+
150
+ def mark_running(self, job_id: str) -> None:
151
+ with self._lock:
152
+ job = self._jobs.get(job_id)
153
+ if job is None:
154
+ logger.error("mark_running called for unknown job %s", job_id)
155
+ return
156
+ if job.status != "queued":
157
+ return
158
+ if job.cancellation_requested:
159
+ job.status = "cancelled"
160
+ job.error = "Cancelled by user"
161
+ job.finished_at = _utc_now_iso()
162
+ self._in_flight.pop(job.cache_key, None)
163
+ return
164
+ job.status = "running"
165
+ job.started_at = _utc_now_iso()
166
+
167
+ def _refresh_current_service(self, job: ScanJob) -> None:
168
+ active = sorted(set(job.active_services))
169
+ if not active:
170
+ job.current_service = "stop requested" if job.cancellation_requested and job.status in {"queued", "running"} else None
171
+ return
172
+ if len(active) == 1:
173
+ label = active[0]
174
+ elif len(active) == 2:
175
+ label = ", ".join(active)
176
+ else:
177
+ preview = ", ".join(active[:2])
178
+ label = f"{len(active)} active ({preview}...)"
179
+ job.current_service = f"{label} | stop requested" if job.cancellation_requested else label
180
+
181
+ def update_progress(
182
+ self,
183
+ job_id: str,
184
+ *,
185
+ event: str,
186
+ current_service: Optional[str],
187
+ services_done: int,
188
+ services_total: int,
189
+ ) -> None:
190
+ with self._lock:
191
+ job = self._jobs.get(job_id)
192
+ if job is None:
193
+ logger.error("update_progress called for unknown job %s", job_id)
194
+ return
195
+ if job.status not in {"queued", "running"}:
196
+ return
197
+ if current_service:
198
+ if event == "start":
199
+ if current_service not in job.active_services:
200
+ job.active_services.append(current_service)
201
+ elif event == "finish":
202
+ job.active_services = [service for service in job.active_services if service != current_service]
203
+ job.services_done = services_done
204
+ job.services_total = services_total
205
+ job.progress_percent = _progress_percent(services_done, services_total)
206
+ if job.status == "queued":
207
+ job.status = "running"
208
+ job.started_at = _utc_now_iso()
209
+ self._refresh_current_service(job)
210
+
211
+ def mark_completed(self, job_id: str, *, ttl_seconds: int) -> None:
212
+ with self._lock:
213
+ job = self._jobs.get(job_id)
214
+ if job is None:
215
+ logger.error("mark_completed called for unknown job %s", job_id)
216
+ return
217
+ if job.status not in {"queued", "running"}:
218
+ return
219
+ job.status = "completed"
220
+ job.progress_percent = 100
221
+ job.current_service = None
222
+ job.services_done = job.services_total
223
+ job.finished_at = _utc_now_iso()
224
+ job.active_services = []
225
+ self._latest_graph_id = job_id
226
+
227
+ self._in_flight.pop(job.cache_key, None)
228
+ self._cache[job.cache_key] = CacheEntry(
229
+ job_id=job_id,
230
+ expires_at=datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds),
231
+ )
232
+
233
+ def mark_failed(self, job_id: str, error: str) -> None:
234
+ with self._lock:
235
+ job = self._jobs.get(job_id)
236
+ if job is None:
237
+ logger.error("mark_failed called for unknown job %s (error: %s)", job_id, error)
238
+ return
239
+ if job.status not in {"queued", "running"}:
240
+ return
241
+ job.status = "failed"
242
+ job.error = error
243
+ job.current_service = None
244
+ job.active_services = []
245
+ job.finished_at = _utc_now_iso()
246
+ self._in_flight.pop(job.cache_key, None)
247
+
248
+ def request_cancel(self, job_id: str) -> bool:
249
+ with self._lock:
250
+ if job_id not in self._jobs:
251
+ raise KeyError(job_id)
252
+ job = self._jobs[job_id]
253
+ if job.status not in {"queued", "running"}:
254
+ return False
255
+ job.cancellation_requested = True
256
+ if job.status == "queued":
257
+ job.status = "cancelled"
258
+ job.error = "Cancelled by user"
259
+ job.current_service = None
260
+ job.active_services = []
261
+ job.finished_at = _utc_now_iso()
262
+ self._in_flight.pop(job.cache_key, None)
263
+ return True
264
+ self._refresh_current_service(job)
265
+ return True
266
+
267
+ def is_cancel_requested(self, job_id: str) -> bool:
268
+ with self._lock:
269
+ if job_id not in self._jobs:
270
+ return False
271
+ return self._jobs[job_id].cancellation_requested
272
+
273
+ def mark_cancelled(self, job_id: str, reason: str = "Cancelled by user") -> None:
274
+ with self._lock:
275
+ if job_id not in self._jobs:
276
+ return
277
+ job = self._jobs[job_id]
278
+ if job.status not in {"queued", "running", "cancelled"}:
279
+ return
280
+ job.cancellation_requested = True
281
+ job.status = "cancelled"
282
+ job.error = reason
283
+ job.current_service = None
284
+ job.active_services = []
285
+ job.finished_at = _utc_now_iso()
286
+ self._in_flight.pop(job.cache_key, None)
287
+
288
+ def get_job(self, job_id: str) -> ScanJob:
289
+ with self._lock:
290
+ if job_id not in self._jobs:
291
+ raise KeyError(job_id)
292
+ return self._jobs[job_id]
293
+
294
+ def get_status_payload(self, job_id: str) -> Dict[str, Any]:
295
+ with self._lock:
296
+ if job_id not in self._jobs:
297
+ raise KeyError(job_id)
298
+ job = self._jobs[job_id]
299
+ job_snapshot = {
300
+ "job_id": job.id,
301
+ "status": job.status,
302
+ "cancellation_requested": job.cancellation_requested,
303
+ "mode": job.mode,
304
+ "region": job.region,
305
+ "services": list(job.services),
306
+ "progress_percent": job.progress_percent,
307
+ "current_service": job.current_service,
308
+ "services_done": job.services_done,
309
+ "services_total": job.services_total,
310
+ "created_at": job.created_at,
311
+ "started_at": job.started_at,
312
+ "finished_at": job.finished_at,
313
+ "error": job.error,
314
+ }
315
+ graph_store = job.graph_store
316
+ graph_payload = graph_store.get_graph_payload()
317
+ metadata = graph_payload.get("metadata", {})
318
+ return {
319
+ **job_snapshot,
320
+ "node_count": metadata.get("node_count", 0),
321
+ "edge_count": metadata.get("edge_count", 0),
322
+ "warnings": metadata.get("warnings", []),
323
+ }
324
+
325
+ def get_graph_payload(self, job_id: str) -> Dict[str, Any]:
326
+ job = self.get_job(job_id)
327
+ return job.graph_store.get_graph_payload()
328
+
329
+ def get_latest_graph_payload(self) -> Dict[str, Any]:
330
+ with self._lock:
331
+ latest_id = self._latest_graph_id
332
+ if not latest_id:
333
+ return GraphStore().get_graph_payload()
334
+ return self.get_graph_payload(latest_id)
335
+
336
+ def get_resource_payload(self, resource_id: str, job_id: Optional[str] = None) -> Dict[str, Any]:
337
+ if job_id:
338
+ job = self.get_job(job_id)
339
+ return job.graph_store.get_resource_payload(resource_id)
340
+
341
+ with self._lock:
342
+ latest_id = self._latest_graph_id
343
+ if not latest_id:
344
+ raise KeyError(resource_id)
345
+ job = self.get_job(latest_id)
346
+ return job.graph_store.get_resource_payload(resource_id)
347
+
348
+ @staticmethod
349
+ def build_cache_key(
350
+ *,
351
+ account_id: str,
352
+ region: str,
353
+ services: List[str],
354
+ mode: ScanMode,
355
+ include_iam_inference: bool,
356
+ include_resource_describes: bool,
357
+ ) -> str:
358
+ ordered_services = ",".join(sorted(services))
359
+ return "|".join(
360
+ [
361
+ account_id,
362
+ region,
363
+ ordered_services,
364
+ mode,
365
+ f"iam={int(include_iam_inference)}",
366
+ f"describe={int(include_resource_describes)}",
367
+ ]
368
+ )