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.
- cloudwire/__init__.py +3 -0
- cloudwire/app/__init__.py +1 -0
- cloudwire/app/graph_store.py +88 -0
- cloudwire/app/main.py +453 -0
- cloudwire/app/models.py +83 -0
- cloudwire/app/scan_jobs.py +368 -0
- cloudwire/app/scanner.py +1149 -0
- cloudwire/cli.py +86 -0
- cloudwire/static/assets/index-CByMF4j6.js +40 -0
- cloudwire/static/assets/index-lik1Sxh5.css +1 -0
- cloudwire/static/index.html +13 -0
- cloudwire-0.1.0.dist-info/METADATA +186 -0
- cloudwire-0.1.0.dist-info/RECORD +16 -0
- cloudwire-0.1.0.dist-info/WHEEL +5 -0
- cloudwire-0.1.0.dist-info/entry_points.txt +2 -0
- cloudwire-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|