clonebox 1.1.13__tar.gz → 1.1.15__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 (70) hide show
  1. {clonebox-1.1.13/src/clonebox.egg-info → clonebox-1.1.15}/PKG-INFO +5 -1
  2. {clonebox-1.1.13 → clonebox-1.1.15}/pyproject.toml +7 -1
  3. clonebox-1.1.15/src/clonebox/audit.py +452 -0
  4. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/cli.py +966 -10
  5. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/cloner.py +221 -135
  6. clonebox-1.1.15/src/clonebox/orchestrator.py +568 -0
  7. clonebox-1.1.15/src/clonebox/plugins/__init__.py +24 -0
  8. clonebox-1.1.15/src/clonebox/plugins/base.py +319 -0
  9. clonebox-1.1.15/src/clonebox/plugins/manager.py +523 -0
  10. clonebox-1.1.15/src/clonebox/remote.py +511 -0
  11. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/secrets.py +9 -6
  12. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/validator.py +113 -41
  13. {clonebox-1.1.13 → clonebox-1.1.15/src/clonebox.egg-info}/PKG-INFO +5 -1
  14. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox.egg-info/SOURCES.txt +11 -0
  15. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox.egg-info/requires.txt +6 -0
  16. clonebox-1.1.15/tests/test_audit.py +312 -0
  17. clonebox-1.1.15/tests/test_cli_new_commands.py +484 -0
  18. {clonebox-1.1.13 → clonebox-1.1.15}/tests/test_coverage_boost_final.py +1 -1
  19. clonebox-1.1.15/tests/test_orchestrator.py +302 -0
  20. clonebox-1.1.15/tests/test_plugins.py +448 -0
  21. clonebox-1.1.15/tests/test_remote.py +388 -0
  22. {clonebox-1.1.13 → clonebox-1.1.15}/LICENSE +0 -0
  23. {clonebox-1.1.13 → clonebox-1.1.15}/README.md +0 -0
  24. {clonebox-1.1.13 → clonebox-1.1.15}/setup.cfg +0 -0
  25. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/__init__.py +0 -0
  26. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/__main__.py +0 -0
  27. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/backends/libvirt_backend.py +0 -0
  28. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/backends/qemu_disk.py +0 -0
  29. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/backends/subprocess_runner.py +0 -0
  30. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/container.py +0 -0
  31. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/dashboard.py +0 -0
  32. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/detector.py +0 -0
  33. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/di.py +0 -0
  34. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/exporter.py +0 -0
  35. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/health/__init__.py +0 -0
  36. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/health/manager.py +0 -0
  37. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/health/models.py +0 -0
  38. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/health/probes.py +0 -0
  39. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/importer.py +0 -0
  40. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/interfaces/disk.py +0 -0
  41. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/interfaces/hypervisor.py +0 -0
  42. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/interfaces/network.py +0 -0
  43. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/interfaces/process.py +0 -0
  44. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/logging.py +0 -0
  45. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/models.py +0 -0
  46. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/monitor.py +0 -0
  47. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/p2p.py +0 -0
  48. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/profiles.py +0 -0
  49. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/resource_monitor.py +0 -0
  50. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/resources.py +0 -0
  51. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/rollback.py +0 -0
  52. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/snapshots/__init__.py +0 -0
  53. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/snapshots/manager.py +0 -0
  54. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/snapshots/models.py +0 -0
  55. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/templates/profiles/ml-dev.yaml +0 -0
  56. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox/templates/profiles/web-stack.yaml +0 -0
  57. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox.egg-info/dependency_links.txt +0 -0
  58. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox.egg-info/entry_points.txt +0 -0
  59. {clonebox-1.1.13 → clonebox-1.1.15}/src/clonebox.egg-info/top_level.txt +0 -0
  60. {clonebox-1.1.13 → clonebox-1.1.15}/tests/test_cli.py +0 -0
  61. {clonebox-1.1.13 → clonebox-1.1.15}/tests/test_cloner.py +0 -0
  62. {clonebox-1.1.13 → clonebox-1.1.15}/tests/test_cloner_simple.py +0 -0
  63. {clonebox-1.1.13 → clonebox-1.1.15}/tests/test_container.py +0 -0
  64. {clonebox-1.1.13 → clonebox-1.1.15}/tests/test_coverage_additional.py +0 -0
  65. {clonebox-1.1.13 → clonebox-1.1.15}/tests/test_dashboard_coverage.py +0 -0
  66. {clonebox-1.1.13 → clonebox-1.1.15}/tests/test_detector.py +0 -0
  67. {clonebox-1.1.13 → clonebox-1.1.15}/tests/test_models.py +0 -0
  68. {clonebox-1.1.13 → clonebox-1.1.15}/tests/test_network.py +0 -0
  69. {clonebox-1.1.13 → clonebox-1.1.15}/tests/test_profiles.py +0 -0
  70. {clonebox-1.1.13 → clonebox-1.1.15}/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.15
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
@@ -49,6 +49,10 @@ Requires-Dist: fastapi>=0.100.0; extra == "test"
49
49
  Provides-Extra: dashboard
50
50
  Requires-Dist: fastapi>=0.100.0; extra == "dashboard"
51
51
  Requires-Dist: uvicorn>=0.22.0; extra == "dashboard"
52
+ Provides-Extra: secrets
53
+ Requires-Dist: hvac>=2.0.0; extra == "secrets"
54
+ Provides-Extra: full
55
+ Requires-Dist: hvac>=2.0.0; extra == "full"
52
56
  Dynamic: license-file
53
57
 
54
58
  # CloneBox 📦
@@ -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.15"
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"}
@@ -61,6 +61,12 @@ dashboard = [
61
61
  "fastapi>=0.100.0",
62
62
  "uvicorn>=0.22.0",
63
63
  ]
64
+ secrets = [
65
+ "hvac>=2.0.0",
66
+ ]
67
+ full = [
68
+ "hvac>=2.0.0",
69
+ ]
64
70
 
65
71
  [project.scripts]
66
72
  clonebox = "clonebox.cli:main"
@@ -0,0 +1,452 @@
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
+ try:
232
+ self.log_path.parent.mkdir(parents=True, exist_ok=True)
233
+ except (PermissionError, OSError):
234
+ # If we can't create log directory, disable audit logging
235
+ self.enabled = False
236
+
237
+ def log(
238
+ self,
239
+ event_type: AuditEventType,
240
+ outcome: AuditOutcome,
241
+ target_type: Optional[str] = None,
242
+ target_name: Optional[str] = None,
243
+ details: Optional[Dict[str, Any]] = None,
244
+ error_message: Optional[str] = None,
245
+ ) -> AuditEvent:
246
+ """Log an audit event."""
247
+ event = AuditEvent(
248
+ event_type=event_type,
249
+ timestamp=datetime.now(),
250
+ outcome=outcome,
251
+ user=self._user,
252
+ hostname=self._hostname,
253
+ pid=self._pid,
254
+ target_type=target_type,
255
+ target_name=target_name,
256
+ details=details or {},
257
+ error_message=error_message,
258
+ correlation_id=self._correlation_id,
259
+ )
260
+
261
+ if self.enabled:
262
+ self._write_event(event)
263
+
264
+ return event
265
+
266
+ def _write_event(self, event: AuditEvent) -> None:
267
+ """Write event to log file."""
268
+ with self._lock:
269
+ try:
270
+ with open(self.log_path, "a") as f:
271
+ f.write(event.to_json() + "\n")
272
+ except Exception as e:
273
+ import sys
274
+ print(f"Audit log write failed: {e}", file=sys.stderr)
275
+ print(event.to_json(), file=sys.stderr)
276
+
277
+ if self.console_echo:
278
+ print(f"[AUDIT] {event.event_type.value}: {event.outcome.value}")
279
+
280
+ def set_correlation_id(self, correlation_id: str) -> None:
281
+ """Set correlation ID for subsequent events."""
282
+ self._correlation_id = correlation_id
283
+
284
+ def clear_correlation_id(self) -> None:
285
+ """Clear correlation ID."""
286
+ self._correlation_id = None
287
+
288
+ @contextmanager
289
+ def operation(
290
+ self,
291
+ event_type: AuditEventType,
292
+ target_type: Optional[str] = None,
293
+ target_name: Optional[str] = None,
294
+ ) -> Generator[AuditContext, None, None]:
295
+ """
296
+ Context manager for auditing an operation.
297
+
298
+ Usage:
299
+ with audit.operation(AuditEventType.VM_CREATE, "vm", "my-vm") as ctx:
300
+ ctx.add_detail("config_path", "/path/to/config")
301
+ do_operation()
302
+ """
303
+ ctx = AuditContext(
304
+ _logger=self,
305
+ _event_type=event_type,
306
+ _target_type=target_type,
307
+ _target_name=target_name,
308
+ )
309
+ try:
310
+ yield ctx
311
+ if ctx._outcome == AuditOutcome.SUCCESS:
312
+ pass # Keep success
313
+ except Exception as e:
314
+ ctx._outcome = AuditOutcome.FAILURE
315
+ ctx._error = str(e)
316
+ raise
317
+ finally:
318
+ self.log(
319
+ event_type=event_type,
320
+ outcome=ctx._outcome,
321
+ target_type=target_type,
322
+ target_name=target_name,
323
+ details=ctx._details,
324
+ error_message=ctx._error,
325
+ )
326
+
327
+
328
+ class AuditQuery:
329
+ """Query audit logs."""
330
+
331
+ def __init__(self, log_path: Optional[Path] = None):
332
+ if log_path:
333
+ self.log_path = log_path
334
+ else:
335
+ self.log_path = Path.home() / ".local" / "share" / "clonebox" / "audit.log"
336
+
337
+ def query(
338
+ self,
339
+ event_type: Optional[AuditEventType] = None,
340
+ target_name: Optional[str] = None,
341
+ user: Optional[str] = None,
342
+ start_time: Optional[datetime] = None,
343
+ end_time: Optional[datetime] = None,
344
+ outcome: Optional[AuditOutcome] = None,
345
+ limit: int = 100,
346
+ ) -> List[AuditEvent]:
347
+ """Query audit events with filters."""
348
+ results: List[AuditEvent] = []
349
+
350
+ if not self.log_path.exists():
351
+ return results
352
+
353
+ with open(self.log_path) as f:
354
+ for line in f:
355
+ if len(results) >= limit:
356
+ break
357
+
358
+ try:
359
+ data = json.loads(line)
360
+
361
+ # Apply filters
362
+ if event_type and data.get("event_type") != event_type.value:
363
+ continue
364
+ if target_name:
365
+ target = data.get("target") or {}
366
+ if target.get("name") != target_name:
367
+ continue
368
+ if user:
369
+ actor = data.get("actor") or {}
370
+ if actor.get("user") != user:
371
+ continue
372
+ if outcome and data.get("outcome") != outcome.value:
373
+ continue
374
+
375
+ event_time = datetime.fromisoformat(data["timestamp"])
376
+ if start_time and event_time < start_time:
377
+ continue
378
+ if end_time and event_time > end_time:
379
+ continue
380
+
381
+ event = AuditEvent.from_dict(data)
382
+ results.append(event)
383
+
384
+ except (json.JSONDecodeError, KeyError, ValueError):
385
+ continue
386
+
387
+ return results
388
+
389
+ def get_recent(self, count: int = 20) -> List[AuditEvent]:
390
+ """Get most recent audit events."""
391
+ all_events = self.query(limit=10000)
392
+ return all_events[-count:] if len(all_events) > count else all_events
393
+
394
+ def get_by_target(self, target_name: str, limit: int = 50) -> List[AuditEvent]:
395
+ """Get events for a specific target."""
396
+ return self.query(target_name=target_name, limit=limit)
397
+
398
+ def get_failures(self, limit: int = 50) -> List[AuditEvent]:
399
+ """Get failed events."""
400
+ return self.query(outcome=AuditOutcome.FAILURE, limit=limit)
401
+
402
+ def get_by_correlation(self, correlation_id: str) -> List[AuditEvent]:
403
+ """Get events by correlation ID."""
404
+ results: List[AuditEvent] = []
405
+
406
+ if not self.log_path.exists():
407
+ return results
408
+
409
+ with open(self.log_path) as f:
410
+ for line in f:
411
+ try:
412
+ data = json.loads(line)
413
+ if data.get("correlation_id") == correlation_id:
414
+ results.append(AuditEvent.from_dict(data))
415
+ except (json.JSONDecodeError, KeyError, ValueError):
416
+ continue
417
+
418
+ return results
419
+
420
+
421
+ # Global audit logger
422
+ _audit_logger: Optional[AuditLogger] = None
423
+
424
+
425
+ def get_audit_logger() -> AuditLogger:
426
+ """Get the global audit logger."""
427
+ global _audit_logger
428
+ if _audit_logger is None:
429
+ _audit_logger = AuditLogger()
430
+ return _audit_logger
431
+
432
+
433
+ def set_audit_logger(logger: AuditLogger) -> None:
434
+ """Set the global audit logger (useful for testing)."""
435
+ global _audit_logger
436
+ _audit_logger = logger
437
+
438
+
439
+ def audit_operation(
440
+ event_type: AuditEventType,
441
+ target_type: Optional[str] = None,
442
+ target_name: Optional[str] = None,
443
+ ) -> Generator[AuditContext, None, None]:
444
+ """
445
+ Convenience function for auditing an operation.
446
+
447
+ Usage:
448
+ with audit_operation(AuditEventType.VM_CREATE, "vm", "my-vm") as ctx:
449
+ ctx.add_detail("config_path", "/path/to/config")
450
+ do_operation()
451
+ """
452
+ return get_audit_logger().operation(event_type, target_type, target_name)