clonebox 1.1.13__tar.gz → 1.1.14__tar.gz

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.

Potentially problematic release.


This version of clonebox might be problematic. Click here for more details.

Files changed (67) hide show
  1. {clonebox-1.1.13/src/clonebox.egg-info → clonebox-1.1.14}/PKG-INFO +1 -1
  2. {clonebox-1.1.13 → clonebox-1.1.14}/pyproject.toml +1 -1
  3. clonebox-1.1.14/src/clonebox/audit.py +448 -0
  4. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/cli.py +398 -5
  5. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/cloner.py +40 -12
  6. clonebox-1.1.14/src/clonebox/orchestrator.py +568 -0
  7. clonebox-1.1.14/src/clonebox/plugins/__init__.py +24 -0
  8. clonebox-1.1.14/src/clonebox/plugins/base.py +319 -0
  9. clonebox-1.1.14/src/clonebox/plugins/manager.py +438 -0
  10. {clonebox-1.1.13 → clonebox-1.1.14/src/clonebox.egg-info}/PKG-INFO +1 -1
  11. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox.egg-info/SOURCES.txt +8 -0
  12. clonebox-1.1.14/tests/test_audit.py +312 -0
  13. clonebox-1.1.14/tests/test_orchestrator.py +302 -0
  14. clonebox-1.1.14/tests/test_plugins.py +448 -0
  15. {clonebox-1.1.13 → clonebox-1.1.14}/LICENSE +0 -0
  16. {clonebox-1.1.13 → clonebox-1.1.14}/README.md +0 -0
  17. {clonebox-1.1.13 → clonebox-1.1.14}/setup.cfg +0 -0
  18. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/__init__.py +0 -0
  19. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/__main__.py +0 -0
  20. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/backends/libvirt_backend.py +0 -0
  21. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/backends/qemu_disk.py +0 -0
  22. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/backends/subprocess_runner.py +0 -0
  23. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/container.py +0 -0
  24. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/dashboard.py +0 -0
  25. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/detector.py +0 -0
  26. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/di.py +0 -0
  27. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/exporter.py +0 -0
  28. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/health/__init__.py +0 -0
  29. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/health/manager.py +0 -0
  30. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/health/models.py +0 -0
  31. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/health/probes.py +0 -0
  32. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/importer.py +0 -0
  33. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/interfaces/disk.py +0 -0
  34. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/interfaces/hypervisor.py +0 -0
  35. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/interfaces/network.py +0 -0
  36. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/interfaces/process.py +0 -0
  37. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/logging.py +0 -0
  38. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/models.py +0 -0
  39. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/monitor.py +0 -0
  40. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/p2p.py +0 -0
  41. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/profiles.py +0 -0
  42. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/resource_monitor.py +0 -0
  43. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/resources.py +0 -0
  44. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/rollback.py +0 -0
  45. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/secrets.py +0 -0
  46. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/snapshots/__init__.py +0 -0
  47. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/snapshots/manager.py +0 -0
  48. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/snapshots/models.py +0 -0
  49. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/templates/profiles/ml-dev.yaml +0 -0
  50. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/templates/profiles/web-stack.yaml +0 -0
  51. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox/validator.py +0 -0
  52. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox.egg-info/dependency_links.txt +0 -0
  53. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox.egg-info/entry_points.txt +0 -0
  54. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox.egg-info/requires.txt +0 -0
  55. {clonebox-1.1.13 → clonebox-1.1.14}/src/clonebox.egg-info/top_level.txt +0 -0
  56. {clonebox-1.1.13 → clonebox-1.1.14}/tests/test_cli.py +0 -0
  57. {clonebox-1.1.13 → clonebox-1.1.14}/tests/test_cloner.py +0 -0
  58. {clonebox-1.1.13 → clonebox-1.1.14}/tests/test_cloner_simple.py +0 -0
  59. {clonebox-1.1.13 → clonebox-1.1.14}/tests/test_container.py +0 -0
  60. {clonebox-1.1.13 → clonebox-1.1.14}/tests/test_coverage_additional.py +0 -0
  61. {clonebox-1.1.13 → clonebox-1.1.14}/tests/test_coverage_boost_final.py +0 -0
  62. {clonebox-1.1.13 → clonebox-1.1.14}/tests/test_dashboard_coverage.py +0 -0
  63. {clonebox-1.1.13 → clonebox-1.1.14}/tests/test_detector.py +0 -0
  64. {clonebox-1.1.13 → clonebox-1.1.14}/tests/test_models.py +0 -0
  65. {clonebox-1.1.13 → clonebox-1.1.14}/tests/test_network.py +0 -0
  66. {clonebox-1.1.13 → clonebox-1.1.14}/tests/test_profiles.py +0 -0
  67. {clonebox-1.1.13 → clonebox-1.1.14}/tests/test_validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clonebox
3
- Version: 1.1.13
3
+ Version: 1.1.14
4
4
  Summary: Clone your workstation environment to an isolated VM with selective apps, paths and services
5
5
  Author: CloneBox Team
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "clonebox"
7
- version = "1.1.13"
7
+ version = "1.1.14"
8
8
  description = "Clone your workstation environment to an isolated VM with selective apps, paths and services"
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -0,0 +1,448 @@
1
+ """
2
+ Audit logging for CloneBox operations.
3
+ Records all significant actions for compliance and debugging.
4
+ """
5
+ from contextlib import contextmanager
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+ from enum import Enum
9
+ from pathlib import Path
10
+ from typing import Optional, Dict, Any, List, Generator
11
+ import json
12
+ import os
13
+ import threading
14
+ import hashlib
15
+
16
+
17
+ class AuditEventType(Enum):
18
+ """Types of auditable events."""
19
+ # VM Operations
20
+ VM_CREATE = "vm.create"
21
+ VM_START = "vm.start"
22
+ VM_STOP = "vm.stop"
23
+ VM_DELETE = "vm.delete"
24
+ VM_RESTART = "vm.restart"
25
+ VM_SNAPSHOT_CREATE = "vm.snapshot.create"
26
+ VM_SNAPSHOT_RESTORE = "vm.snapshot.restore"
27
+ VM_SNAPSHOT_DELETE = "vm.snapshot.delete"
28
+ VM_EXPORT = "vm.export"
29
+ VM_IMPORT = "vm.import"
30
+
31
+ # Configuration
32
+ CONFIG_CREATE = "config.create"
33
+ CONFIG_MODIFY = "config.modify"
34
+ CONFIG_DELETE = "config.delete"
35
+ CONFIG_LOAD = "config.load"
36
+
37
+ # Secrets
38
+ SECRETS_ACCESS = "secrets.access"
39
+ SECRETS_MODIFY = "secrets.modify"
40
+
41
+ # Authentication
42
+ AUTH_SSH_KEY_GENERATED = "auth.ssh_key.generated"
43
+ AUTH_PASSWORD_GENERATED = "auth.password.generated"
44
+
45
+ # Health
46
+ HEALTH_CHECK_RUN = "health.check.run"
47
+ HEALTH_CHECK_FAILED = "health.check.failed"
48
+ HEALTH_CHECK_PASSED = "health.check.passed"
49
+
50
+ # Repair
51
+ REPAIR_TRIGGERED = "repair.triggered"
52
+ REPAIR_COMPLETED = "repair.completed"
53
+ REPAIR_FAILED = "repair.failed"
54
+
55
+ # Container Operations
56
+ CONTAINER_UP = "container.up"
57
+ CONTAINER_STOP = "container.stop"
58
+ CONTAINER_RM = "container.rm"
59
+
60
+ # P2P Operations
61
+ P2P_EXPORT = "p2p.export"
62
+ P2P_IMPORT = "p2p.import"
63
+ P2P_SYNC_KEY = "p2p.sync_key"
64
+
65
+ # System
66
+ SYSTEM_ERROR = "system.error"
67
+ SYSTEM_WARNING = "system.warning"
68
+ SYSTEM_STARTUP = "system.startup"
69
+ SYSTEM_SHUTDOWN = "system.shutdown"
70
+
71
+
72
+ class AuditOutcome(Enum):
73
+ """Outcome of an audited operation."""
74
+ SUCCESS = "success"
75
+ FAILURE = "failure"
76
+ PARTIAL = "partial"
77
+ DENIED = "denied"
78
+ SKIPPED = "skipped"
79
+
80
+
81
+ @dataclass
82
+ class AuditEvent:
83
+ """A single audit event."""
84
+ event_type: AuditEventType
85
+ timestamp: datetime
86
+ outcome: AuditOutcome
87
+
88
+ # Actor information
89
+ user: str
90
+ hostname: str
91
+ pid: int
92
+
93
+ # Target information
94
+ target_type: Optional[str] = None # "vm", "config", "snapshot", "container"
95
+ target_name: Optional[str] = None
96
+
97
+ # Details
98
+ details: Dict[str, Any] = field(default_factory=dict)
99
+ error_message: Optional[str] = None
100
+
101
+ # Correlation
102
+ correlation_id: Optional[str] = None
103
+ parent_event_id: Optional[str] = None
104
+
105
+ # Computed
106
+ event_id: str = field(default_factory=lambda: "")
107
+
108
+ def __post_init__(self) -> None:
109
+ if not self.event_id:
110
+ content = f"{self.timestamp.isoformat()}{self.event_type.value}{self.user}{self.pid}"
111
+ self.event_id = hashlib.sha256(content.encode()).hexdigest()[:16]
112
+
113
+ def to_dict(self) -> Dict[str, Any]:
114
+ """Convert to dictionary for serialization."""
115
+ return {
116
+ "event_id": self.event_id,
117
+ "event_type": self.event_type.value,
118
+ "timestamp": self.timestamp.isoformat(),
119
+ "outcome": self.outcome.value,
120
+ "actor": {
121
+ "user": self.user,
122
+ "hostname": self.hostname,
123
+ "pid": self.pid,
124
+ },
125
+ "target": {
126
+ "type": self.target_type,
127
+ "name": self.target_name,
128
+ } if self.target_type else None,
129
+ "details": self.details,
130
+ "error_message": self.error_message,
131
+ "correlation_id": self.correlation_id,
132
+ "parent_event_id": self.parent_event_id,
133
+ }
134
+
135
+ def to_json(self) -> str:
136
+ """Convert to JSON string."""
137
+ return json.dumps(self.to_dict(), default=str)
138
+
139
+ @classmethod
140
+ def from_dict(cls, data: Dict[str, Any]) -> "AuditEvent":
141
+ """Create from dictionary."""
142
+ actor = data.get("actor", {})
143
+ target = data.get("target", {}) or {}
144
+ return cls(
145
+ event_id=data.get("event_id", ""),
146
+ event_type=AuditEventType(data["event_type"]),
147
+ timestamp=datetime.fromisoformat(data["timestamp"]),
148
+ outcome=AuditOutcome(data["outcome"]),
149
+ user=actor.get("user", "unknown"),
150
+ hostname=actor.get("hostname", "unknown"),
151
+ pid=actor.get("pid", 0),
152
+ target_type=target.get("type"),
153
+ target_name=target.get("name"),
154
+ details=data.get("details", {}),
155
+ error_message=data.get("error_message"),
156
+ correlation_id=data.get("correlation_id"),
157
+ parent_event_id=data.get("parent_event_id"),
158
+ )
159
+
160
+
161
+ @dataclass
162
+ class AuditContext:
163
+ """Context for an audited operation."""
164
+ _logger: "AuditLogger"
165
+ _event_type: AuditEventType
166
+ _target_type: Optional[str]
167
+ _target_name: Optional[str]
168
+ _details: Dict[str, Any] = field(default_factory=dict)
169
+ _outcome: AuditOutcome = AuditOutcome.SUCCESS
170
+ _error: Optional[str] = None
171
+
172
+ def add_detail(self, key: str, value: Any) -> None:
173
+ """Add a detail to the audit event."""
174
+ self._details[key] = value
175
+
176
+ def set_outcome(self, outcome: AuditOutcome) -> None:
177
+ """Set the outcome (overrides automatic detection)."""
178
+ self._outcome = outcome
179
+
180
+ def set_error(self, error: str) -> None:
181
+ """Set error message."""
182
+ self._error = error
183
+ self._outcome = AuditOutcome.FAILURE
184
+
185
+
186
+ class AuditLogger:
187
+ """
188
+ Audit logger that writes events to file and/or external systems.
189
+
190
+ Usage:
191
+ audit = AuditLogger()
192
+
193
+ with audit.operation(AuditEventType.VM_CREATE, target_type="vm", target_name="my-vm") as ctx:
194
+ ctx.add_detail("disk_size_gb", 30)
195
+ # do stuff
196
+
197
+ # Or manually
198
+ audit.log(AuditEventType.VM_START, outcome=AuditOutcome.SUCCESS, ...)
199
+ """
200
+
201
+ def __init__(
202
+ self,
203
+ log_path: Optional[Path] = None,
204
+ enabled: bool = True,
205
+ console_echo: bool = False,
206
+ ):
207
+ self.enabled = enabled
208
+ self.console_echo = console_echo
209
+ self._lock = threading.Lock()
210
+ self._correlation_id: Optional[str] = None
211
+
212
+ # Determine log path
213
+ if log_path:
214
+ self.log_path = log_path
215
+ else:
216
+ # Use user-local path by default
217
+ local_share = Path.home() / ".local" / "share" / "clonebox"
218
+ self.log_path = local_share / "audit.log"
219
+
220
+ # Get actor info once
221
+ self._user = os.environ.get("USER", os.environ.get("USERNAME", "unknown"))
222
+ try:
223
+ self._hostname = os.uname().nodename
224
+ except AttributeError:
225
+ import socket
226
+ self._hostname = socket.gethostname()
227
+ self._pid = os.getpid()
228
+
229
+ # Ensure log directory exists
230
+ if self.enabled:
231
+ self.log_path.parent.mkdir(parents=True, exist_ok=True)
232
+
233
+ def log(
234
+ self,
235
+ event_type: AuditEventType,
236
+ outcome: AuditOutcome,
237
+ target_type: Optional[str] = None,
238
+ target_name: Optional[str] = None,
239
+ details: Optional[Dict[str, Any]] = None,
240
+ error_message: Optional[str] = None,
241
+ ) -> AuditEvent:
242
+ """Log an audit event."""
243
+ event = AuditEvent(
244
+ event_type=event_type,
245
+ timestamp=datetime.now(),
246
+ outcome=outcome,
247
+ user=self._user,
248
+ hostname=self._hostname,
249
+ pid=self._pid,
250
+ target_type=target_type,
251
+ target_name=target_name,
252
+ details=details or {},
253
+ error_message=error_message,
254
+ correlation_id=self._correlation_id,
255
+ )
256
+
257
+ if self.enabled:
258
+ self._write_event(event)
259
+
260
+ return event
261
+
262
+ def _write_event(self, event: AuditEvent) -> None:
263
+ """Write event to log file."""
264
+ with self._lock:
265
+ try:
266
+ with open(self.log_path, "a") as f:
267
+ f.write(event.to_json() + "\n")
268
+ except Exception as e:
269
+ import sys
270
+ print(f"Audit log write failed: {e}", file=sys.stderr)
271
+ print(event.to_json(), file=sys.stderr)
272
+
273
+ if self.console_echo:
274
+ print(f"[AUDIT] {event.event_type.value}: {event.outcome.value}")
275
+
276
+ def set_correlation_id(self, correlation_id: str) -> None:
277
+ """Set correlation ID for subsequent events."""
278
+ self._correlation_id = correlation_id
279
+
280
+ def clear_correlation_id(self) -> None:
281
+ """Clear correlation ID."""
282
+ self._correlation_id = None
283
+
284
+ @contextmanager
285
+ def operation(
286
+ self,
287
+ event_type: AuditEventType,
288
+ target_type: Optional[str] = None,
289
+ target_name: Optional[str] = None,
290
+ ) -> Generator[AuditContext, None, None]:
291
+ """
292
+ Context manager for auditing an operation.
293
+
294
+ Usage:
295
+ with audit.operation(AuditEventType.VM_CREATE, "vm", "my-vm") as ctx:
296
+ ctx.add_detail("config_path", "/path/to/config")
297
+ do_operation()
298
+ """
299
+ ctx = AuditContext(
300
+ _logger=self,
301
+ _event_type=event_type,
302
+ _target_type=target_type,
303
+ _target_name=target_name,
304
+ )
305
+ try:
306
+ yield ctx
307
+ if ctx._outcome == AuditOutcome.SUCCESS:
308
+ pass # Keep success
309
+ except Exception as e:
310
+ ctx._outcome = AuditOutcome.FAILURE
311
+ ctx._error = str(e)
312
+ raise
313
+ finally:
314
+ self.log(
315
+ event_type=event_type,
316
+ outcome=ctx._outcome,
317
+ target_type=target_type,
318
+ target_name=target_name,
319
+ details=ctx._details,
320
+ error_message=ctx._error,
321
+ )
322
+
323
+
324
+ class AuditQuery:
325
+ """Query audit logs."""
326
+
327
+ def __init__(self, log_path: Optional[Path] = None):
328
+ if log_path:
329
+ self.log_path = log_path
330
+ else:
331
+ self.log_path = Path.home() / ".local" / "share" / "clonebox" / "audit.log"
332
+
333
+ def query(
334
+ self,
335
+ event_type: Optional[AuditEventType] = None,
336
+ target_name: Optional[str] = None,
337
+ user: Optional[str] = None,
338
+ start_time: Optional[datetime] = None,
339
+ end_time: Optional[datetime] = None,
340
+ outcome: Optional[AuditOutcome] = None,
341
+ limit: int = 100,
342
+ ) -> List[AuditEvent]:
343
+ """Query audit events with filters."""
344
+ results: List[AuditEvent] = []
345
+
346
+ if not self.log_path.exists():
347
+ return results
348
+
349
+ with open(self.log_path) as f:
350
+ for line in f:
351
+ if len(results) >= limit:
352
+ break
353
+
354
+ try:
355
+ data = json.loads(line)
356
+
357
+ # Apply filters
358
+ if event_type and data.get("event_type") != event_type.value:
359
+ continue
360
+ if target_name:
361
+ target = data.get("target") or {}
362
+ if target.get("name") != target_name:
363
+ continue
364
+ if user:
365
+ actor = data.get("actor") or {}
366
+ if actor.get("user") != user:
367
+ continue
368
+ if outcome and data.get("outcome") != outcome.value:
369
+ continue
370
+
371
+ event_time = datetime.fromisoformat(data["timestamp"])
372
+ if start_time and event_time < start_time:
373
+ continue
374
+ if end_time and event_time > end_time:
375
+ continue
376
+
377
+ event = AuditEvent.from_dict(data)
378
+ results.append(event)
379
+
380
+ except (json.JSONDecodeError, KeyError, ValueError):
381
+ continue
382
+
383
+ return results
384
+
385
+ def get_recent(self, count: int = 20) -> List[AuditEvent]:
386
+ """Get most recent audit events."""
387
+ all_events = self.query(limit=10000)
388
+ return all_events[-count:] if len(all_events) > count else all_events
389
+
390
+ def get_by_target(self, target_name: str, limit: int = 50) -> List[AuditEvent]:
391
+ """Get events for a specific target."""
392
+ return self.query(target_name=target_name, limit=limit)
393
+
394
+ def get_failures(self, limit: int = 50) -> List[AuditEvent]:
395
+ """Get failed events."""
396
+ return self.query(outcome=AuditOutcome.FAILURE, limit=limit)
397
+
398
+ def get_by_correlation(self, correlation_id: str) -> List[AuditEvent]:
399
+ """Get events by correlation ID."""
400
+ results: List[AuditEvent] = []
401
+
402
+ if not self.log_path.exists():
403
+ return results
404
+
405
+ with open(self.log_path) as f:
406
+ for line in f:
407
+ try:
408
+ data = json.loads(line)
409
+ if data.get("correlation_id") == correlation_id:
410
+ results.append(AuditEvent.from_dict(data))
411
+ except (json.JSONDecodeError, KeyError, ValueError):
412
+ continue
413
+
414
+ return results
415
+
416
+
417
+ # Global audit logger
418
+ _audit_logger: Optional[AuditLogger] = None
419
+
420
+
421
+ def get_audit_logger() -> AuditLogger:
422
+ """Get the global audit logger."""
423
+ global _audit_logger
424
+ if _audit_logger is None:
425
+ _audit_logger = AuditLogger()
426
+ return _audit_logger
427
+
428
+
429
+ def set_audit_logger(logger: AuditLogger) -> None:
430
+ """Set the global audit logger (useful for testing)."""
431
+ global _audit_logger
432
+ _audit_logger = logger
433
+
434
+
435
+ def audit_operation(
436
+ event_type: AuditEventType,
437
+ target_type: Optional[str] = None,
438
+ target_name: Optional[str] = None,
439
+ ) -> Generator[AuditContext, None, None]:
440
+ """
441
+ Convenience function for auditing an operation.
442
+
443
+ Usage:
444
+ with audit_operation(AuditEventType.VM_CREATE, "vm", "my-vm") as ctx:
445
+ ctx.add_detail("config_path", "/path/to/config")
446
+ do_operation()
447
+ """
448
+ return get_audit_logger().operation(event_type, target_type, target_name)