golem-vm-provider 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.
- golem_vm_provider-0.1.0.dist-info/METADATA +398 -0
- golem_vm_provider-0.1.0.dist-info/RECORD +26 -0
- golem_vm_provider-0.1.0.dist-info/WHEEL +4 -0
- golem_vm_provider-0.1.0.dist-info/entry_points.txt +3 -0
- provider/__init__.py +3 -0
- provider/api/__init__.py +19 -0
- provider/api/models.py +108 -0
- provider/api/routes.py +159 -0
- provider/config.py +160 -0
- provider/discovery/__init__.py +6 -0
- provider/discovery/advertiser.py +179 -0
- provider/discovery/resource_tracker.py +152 -0
- provider/main.py +125 -0
- provider/network/port_verifier.py +287 -0
- provider/security/ethereum.py +41 -0
- provider/utils/ascii_art.py +79 -0
- provider/utils/logging.py +82 -0
- provider/utils/port_display.py +204 -0
- provider/utils/retry.py +48 -0
- provider/vm/__init__.py +31 -0
- provider/vm/cloud_init.py +67 -0
- provider/vm/models.py +205 -0
- provider/vm/multipass.py +427 -0
- provider/vm/name_mapper.py +108 -0
- provider/vm/port_manager.py +196 -0
- provider/vm/proxy_manager.py +239 -0
provider/vm/multipass.py
ADDED
@@ -0,0 +1,427 @@
|
|
1
|
+
import os
|
2
|
+
import json
|
3
|
+
import subprocess
|
4
|
+
from pathlib import Path
|
5
|
+
from typing import Optional, Dict, List
|
6
|
+
from datetime import datetime
|
7
|
+
|
8
|
+
from ..config import settings
|
9
|
+
from ..utils.logging import setup_logger, PROCESS, SUCCESS
|
10
|
+
from .models import VMInfo, VMStatus, VMCreateRequest, VMConfig, VMProvider, VMError, VMCreateError, VMResources, VMNotFoundError
|
11
|
+
from .cloud_init import generate_cloud_init, cleanup_cloud_init
|
12
|
+
from .proxy_manager import PythonProxyManager
|
13
|
+
from .name_mapper import VMNameMapper
|
14
|
+
|
15
|
+
logger = setup_logger(__name__)
|
16
|
+
|
17
|
+
|
18
|
+
class MultipassError(VMError):
|
19
|
+
"""Raised when multipass operations fail."""
|
20
|
+
pass
|
21
|
+
|
22
|
+
|
23
|
+
class MultipassProvider(VMProvider):
|
24
|
+
"""Manages VMs using Multipass."""
|
25
|
+
|
26
|
+
def __init__(self, resource_tracker: "ResourceTracker", port_manager: "PortManager"):
|
27
|
+
"""Initialize the multipass provider.
|
28
|
+
|
29
|
+
Args:
|
30
|
+
resource_tracker: Resource tracker instance
|
31
|
+
port_manager: Port manager instance for SSH port allocation
|
32
|
+
"""
|
33
|
+
self.resource_tracker = resource_tracker
|
34
|
+
self.port_manager = port_manager
|
35
|
+
self.multipass_path = settings.MULTIPASS_BINARY_PATH
|
36
|
+
self.vm_data_dir = Path(settings.VM_DATA_DIR)
|
37
|
+
self.vm_data_dir.mkdir(parents=True, exist_ok=True)
|
38
|
+
|
39
|
+
# Initialize managers
|
40
|
+
self.proxy_manager = PythonProxyManager()
|
41
|
+
self.name_mapper = VMNameMapper(self.vm_data_dir / "vm_names.json")
|
42
|
+
|
43
|
+
def _verify_installation(self) -> None:
|
44
|
+
"""Verify multipass is installed and get version."""
|
45
|
+
try:
|
46
|
+
result = subprocess.run(
|
47
|
+
[self.multipass_path, "version"],
|
48
|
+
capture_output=True,
|
49
|
+
text=True,
|
50
|
+
check=True
|
51
|
+
)
|
52
|
+
logger.info(f"🔧 Using Multipass version: {result.stdout.strip()}")
|
53
|
+
except subprocess.CalledProcessError as e:
|
54
|
+
raise MultipassError(
|
55
|
+
f"Failed to verify multipass installation: {e.stderr}")
|
56
|
+
except FileNotFoundError:
|
57
|
+
raise MultipassError(
|
58
|
+
f"Multipass not found at {self.multipass_path}")
|
59
|
+
|
60
|
+
def _get_all_vms_resources(self) -> Dict[str, VMResources]:
|
61
|
+
"""Get resources for all running VMs from multipass.
|
62
|
+
|
63
|
+
Returns:
|
64
|
+
Dictionary mapping VM names to their resources
|
65
|
+
"""
|
66
|
+
result = self._run_multipass(["list", "--format", "json"])
|
67
|
+
data = json.loads(result.stdout)
|
68
|
+
vm_resources = {}
|
69
|
+
|
70
|
+
for vm in data.get("list", []):
|
71
|
+
if vm.get("name", "").startswith("golem-"):
|
72
|
+
try:
|
73
|
+
info = self._get_vm_info(vm["name"])
|
74
|
+
vm_resources[vm["name"]] = VMResources(
|
75
|
+
cpu=int(info.get("cpu_count", 1)),
|
76
|
+
memory=int(info.get("memory_total", 1024) / 1024),
|
77
|
+
storage=int(info.get("disk_total", 10 * 1024) / 1024)
|
78
|
+
)
|
79
|
+
except Exception as e:
|
80
|
+
logger.error(f"Failed to get info for VM {vm['name']}: {e}")
|
81
|
+
continue
|
82
|
+
|
83
|
+
return vm_resources
|
84
|
+
|
85
|
+
async def initialize(self) -> None:
|
86
|
+
"""Initialize the provider."""
|
87
|
+
self._verify_installation()
|
88
|
+
|
89
|
+
# Create SSH key directory
|
90
|
+
ssh_key_dir = Path(settings.SSH_KEY_DIR)
|
91
|
+
ssh_key_dir.mkdir(parents=True, exist_ok=True)
|
92
|
+
|
93
|
+
# Sync resource tracker with actual multipass state
|
94
|
+
logger.info("🔄 Syncing resource tracker with multipass state...")
|
95
|
+
vm_resources = self._get_all_vms_resources()
|
96
|
+
await self.resource_tracker.sync_with_multipass(vm_resources)
|
97
|
+
logger.info("✨ Resource tracker synced with multipass state")
|
98
|
+
|
99
|
+
def _run_multipass(self, args: List[str], check: bool = True) -> subprocess.CompletedProcess:
|
100
|
+
"""Run a multipass command.
|
101
|
+
|
102
|
+
Args:
|
103
|
+
args: Command arguments
|
104
|
+
check: Whether to check return code
|
105
|
+
|
106
|
+
Returns:
|
107
|
+
CompletedProcess instance
|
108
|
+
"""
|
109
|
+
try:
|
110
|
+
return subprocess.run(
|
111
|
+
[self.multipass_path, *args],
|
112
|
+
capture_output=True,
|
113
|
+
text=True,
|
114
|
+
check=check
|
115
|
+
)
|
116
|
+
except subprocess.CalledProcessError as e:
|
117
|
+
raise MultipassError(f"Multipass command failed: {e.stderr}")
|
118
|
+
|
119
|
+
def _get_vm_info(self, vm_id: str) -> Dict:
|
120
|
+
"""Get detailed information about a VM.
|
121
|
+
|
122
|
+
Args:
|
123
|
+
vm_id: VM identifier
|
124
|
+
|
125
|
+
Returns:
|
126
|
+
Dictionary with VM information
|
127
|
+
"""
|
128
|
+
result = self._run_multipass(["info", vm_id, "--format", "json"])
|
129
|
+
try:
|
130
|
+
info = json.loads(result.stdout)
|
131
|
+
return info["info"][vm_id]
|
132
|
+
except (json.JSONDecodeError, KeyError) as e:
|
133
|
+
raise MultipassError(f"Failed to parse VM info: {e}")
|
134
|
+
|
135
|
+
def _get_vm_ip(self, vm_id: str) -> Optional[str]:
|
136
|
+
"""Get IP address of a VM.
|
137
|
+
|
138
|
+
Args:
|
139
|
+
vm_id: VM identifier
|
140
|
+
|
141
|
+
Returns:
|
142
|
+
IP address or None if not found
|
143
|
+
"""
|
144
|
+
try:
|
145
|
+
info = self._get_vm_info(vm_id)
|
146
|
+
return info.get("ipv4", [None])[0]
|
147
|
+
except Exception:
|
148
|
+
return None
|
149
|
+
|
150
|
+
async def create_vm(self, config: VMConfig) -> VMInfo:
|
151
|
+
"""Create a new VM.
|
152
|
+
|
153
|
+
Args:
|
154
|
+
config: VM configuration
|
155
|
+
|
156
|
+
Returns:
|
157
|
+
Information about the created VM
|
158
|
+
"""
|
159
|
+
multipass_name = f"golem-{config.name}-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
160
|
+
await self.name_mapper.add_mapping(config.name, multipass_name)
|
161
|
+
|
162
|
+
# Verify resources are properly allocated
|
163
|
+
if not self.resource_tracker.can_accept_resources(config.resources):
|
164
|
+
raise VMCreateError("Resources not properly allocated or insufficient")
|
165
|
+
|
166
|
+
# Generate cloud-init config with requestor's public key
|
167
|
+
cloud_init_path = generate_cloud_init(
|
168
|
+
hostname=config.name,
|
169
|
+
ssh_key=config.ssh_key
|
170
|
+
)
|
171
|
+
|
172
|
+
try:
|
173
|
+
# Launch VM
|
174
|
+
logger.process(f"🚀 Launching VM {multipass_name}")
|
175
|
+
launch_cmd = [
|
176
|
+
"launch",
|
177
|
+
config.image,
|
178
|
+
"--name", multipass_name,
|
179
|
+
"--cloud-init", cloud_init_path,
|
180
|
+
"--cpus", str(config.resources.cpu),
|
181
|
+
"--memory", f"{config.resources.memory}G",
|
182
|
+
"--disk", f"{config.resources.storage}G"
|
183
|
+
]
|
184
|
+
self._run_multipass(launch_cmd)
|
185
|
+
|
186
|
+
# Get VM IP
|
187
|
+
ip_address = self._get_vm_ip(multipass_name)
|
188
|
+
if not ip_address:
|
189
|
+
raise MultipassError("Failed to get VM IP address")
|
190
|
+
|
191
|
+
# Allocate port and configure proxy
|
192
|
+
try:
|
193
|
+
# First allocate a verified port
|
194
|
+
ssh_port = self.port_manager.allocate_port(multipass_name)
|
195
|
+
if not ssh_port:
|
196
|
+
raise MultipassError("Failed to allocate verified SSH port")
|
197
|
+
|
198
|
+
# Then configure proxy with allocated port
|
199
|
+
success = await self.proxy_manager.add_vm(multipass_name, ip_address, port=ssh_port)
|
200
|
+
if not success:
|
201
|
+
# Clean up allocated port if proxy fails
|
202
|
+
self.port_manager.deallocate_port(multipass_name)
|
203
|
+
raise MultipassError("Failed to configure proxy")
|
204
|
+
|
205
|
+
# Create VM info and register with resource tracker
|
206
|
+
vm_info = VMInfo(
|
207
|
+
id=config.name, # Use requestor name as VM ID
|
208
|
+
name=config.name,
|
209
|
+
status=VMStatus.RUNNING,
|
210
|
+
resources=config.resources,
|
211
|
+
ip_address=ip_address,
|
212
|
+
ssh_port=ssh_port
|
213
|
+
)
|
214
|
+
|
215
|
+
# Update resource tracker with VM ID
|
216
|
+
await self.resource_tracker.allocate(config.resources, config.name)
|
217
|
+
|
218
|
+
return vm_info
|
219
|
+
|
220
|
+
except Exception as e:
|
221
|
+
# If proxy configuration fails, ensure we cleanup the VM and resources
|
222
|
+
self._run_multipass(["delete", multipass_name, "--purge"], check=False)
|
223
|
+
await self.resource_tracker.deallocate(config.resources, config.name)
|
224
|
+
await self.name_mapper.remove_mapping(config.name)
|
225
|
+
raise VMCreateError(
|
226
|
+
f"Failed to configure VM networking: {str(e)}", vm_id=config.name)
|
227
|
+
|
228
|
+
except Exception as e:
|
229
|
+
# Cleanup on failure (this catches VM creation errors)
|
230
|
+
try:
|
231
|
+
await self.delete_vm(config.name)
|
232
|
+
except Exception as cleanup_error:
|
233
|
+
logger.error(f"Error during VM cleanup: {cleanup_error}")
|
234
|
+
# Ensure resources are deallocated even if delete_vm fails
|
235
|
+
await self.resource_tracker.deallocate(config.resources, config.name)
|
236
|
+
raise VMCreateError(f"Failed to create VM: {str(e)}", vm_id=config.name)
|
237
|
+
|
238
|
+
finally:
|
239
|
+
# Cleanup cloud-init file
|
240
|
+
cleanup_cloud_init(cloud_init_path)
|
241
|
+
|
242
|
+
def _verify_vm_exists(self, vm_id: str) -> bool:
|
243
|
+
"""Check if VM exists in multipass.
|
244
|
+
|
245
|
+
Args:
|
246
|
+
vm_id: VM identifier
|
247
|
+
|
248
|
+
Returns:
|
249
|
+
True if VM exists, False otherwise
|
250
|
+
"""
|
251
|
+
try:
|
252
|
+
result = self._run_multipass(["list", "--format", "json"])
|
253
|
+
data = json.loads(result.stdout)
|
254
|
+
vms = data.get("list", [])
|
255
|
+
return any(vm.get("name") == vm_id for vm in vms)
|
256
|
+
except Exception:
|
257
|
+
return False
|
258
|
+
|
259
|
+
async def delete_vm(self, requestor_name: str) -> None:
|
260
|
+
"""Delete a VM.
|
261
|
+
|
262
|
+
Args:
|
263
|
+
requestor_name: Requestor's VM name
|
264
|
+
"""
|
265
|
+
# Get multipass name from mapper
|
266
|
+
multipass_name = await self.name_mapper.get_multipass_name(requestor_name)
|
267
|
+
if not multipass_name:
|
268
|
+
logger.warning(f"No multipass name found for VM {requestor_name}")
|
269
|
+
return
|
270
|
+
|
271
|
+
logger.process(f"🗑️ Initiating deletion of VM {multipass_name}")
|
272
|
+
|
273
|
+
# Get VM info for resource deallocation
|
274
|
+
try:
|
275
|
+
vm_info = await self.get_vm_status(requestor_name)
|
276
|
+
except Exception as e:
|
277
|
+
logger.error(f"Failed to get VM info for cleanup: {e}")
|
278
|
+
vm_info = None
|
279
|
+
|
280
|
+
# Check if VM exists
|
281
|
+
if not self._verify_vm_exists(multipass_name):
|
282
|
+
logger.warning(f"VM {multipass_name} not found in multipass")
|
283
|
+
else:
|
284
|
+
try:
|
285
|
+
# First mark for deletion
|
286
|
+
logger.info("🔄 Marking VM for deletion...")
|
287
|
+
self._run_multipass(["delete", multipass_name], check=False)
|
288
|
+
|
289
|
+
# Then purge
|
290
|
+
logger.info("🔄 Purging deleted VM...")
|
291
|
+
self._run_multipass(["purge"], check=False)
|
292
|
+
|
293
|
+
# Verify deletion
|
294
|
+
if self._verify_vm_exists(multipass_name):
|
295
|
+
logger.error(f"VM {multipass_name} still exists after deletion attempt")
|
296
|
+
# Try one more time with force
|
297
|
+
logger.info("🔄 Attempting forced deletion...")
|
298
|
+
self._run_multipass(["stop", "--all", multipass_name], check=False)
|
299
|
+
self._run_multipass(["delete", "--purge", multipass_name], check=False)
|
300
|
+
if self._verify_vm_exists(multipass_name):
|
301
|
+
raise MultipassError(f"Failed to delete VM {multipass_name}")
|
302
|
+
|
303
|
+
logger.success("✨ VM instance removed")
|
304
|
+
except Exception as e:
|
305
|
+
logger.error(f"Error deleting VM {multipass_name} from multipass: {e}")
|
306
|
+
raise
|
307
|
+
|
308
|
+
# Clean up proxy config and port allocation
|
309
|
+
try:
|
310
|
+
logger.info("🔄 Cleaning up network configuration...")
|
311
|
+
await self.proxy_manager.remove_vm(multipass_name)
|
312
|
+
self.port_manager.deallocate_port(multipass_name)
|
313
|
+
logger.success("✨ Network configuration cleaned up")
|
314
|
+
except Exception as e:
|
315
|
+
logger.error(f"Error cleaning up network configuration for VM {multipass_name}: {e}")
|
316
|
+
|
317
|
+
# Deallocate resources
|
318
|
+
if vm_info and vm_info.resources:
|
319
|
+
try:
|
320
|
+
logger.info("🔄 Deallocating resources...")
|
321
|
+
await self.resource_tracker.deallocate(vm_info.resources, requestor_name)
|
322
|
+
logger.success("✨ Resources deallocated")
|
323
|
+
except Exception as e:
|
324
|
+
logger.error(f"Error deallocating resources: {e}")
|
325
|
+
|
326
|
+
# Remove name mapping
|
327
|
+
try:
|
328
|
+
await self.name_mapper.remove_mapping(requestor_name)
|
329
|
+
logger.success("✨ Name mapping removed")
|
330
|
+
except Exception as e:
|
331
|
+
logger.error(f"Error removing name mapping: {e}")
|
332
|
+
|
333
|
+
# Sync resource tracker with actual state
|
334
|
+
logger.info("🔄 Syncing resource tracker with multipass state...")
|
335
|
+
vm_resources = self._get_all_vms_resources()
|
336
|
+
await self.resource_tracker.sync_with_multipass(vm_resources)
|
337
|
+
logger.info("✨ Resource tracker synced with multipass state")
|
338
|
+
|
339
|
+
async def start_vm(self, requestor_name: str) -> VMInfo:
|
340
|
+
"""Start a VM.
|
341
|
+
|
342
|
+
Args:
|
343
|
+
requestor_name: Requestor's VM name
|
344
|
+
|
345
|
+
Returns:
|
346
|
+
Updated VM information
|
347
|
+
"""
|
348
|
+
# Get multipass name from mapper
|
349
|
+
multipass_name = await self.name_mapper.get_multipass_name(requestor_name)
|
350
|
+
if not multipass_name:
|
351
|
+
raise VMNotFoundError(f"VM {requestor_name} not found")
|
352
|
+
|
353
|
+
logger.process(f"🔄 Starting VM '{requestor_name}'")
|
354
|
+
self._run_multipass(["start", multipass_name])
|
355
|
+
status = await self.get_vm_status(requestor_name)
|
356
|
+
logger.success(f"✨ VM '{requestor_name}' started successfully")
|
357
|
+
return status
|
358
|
+
|
359
|
+
async def stop_vm(self, requestor_name: str) -> VMInfo:
|
360
|
+
"""Stop a VM.
|
361
|
+
|
362
|
+
Args:
|
363
|
+
requestor_name: Requestor's VM name
|
364
|
+
|
365
|
+
Returns:
|
366
|
+
Updated VM information
|
367
|
+
"""
|
368
|
+
# Get multipass name from mapper
|
369
|
+
multipass_name = await self.name_mapper.get_multipass_name(requestor_name)
|
370
|
+
if not multipass_name:
|
371
|
+
raise VMNotFoundError(f"VM {requestor_name} not found")
|
372
|
+
|
373
|
+
logger.process(f"🔄 Stopping VM '{requestor_name}'")
|
374
|
+
self._run_multipass(["stop", multipass_name])
|
375
|
+
status = await self.get_vm_status(requestor_name)
|
376
|
+
logger.success(f"✨ VM '{requestor_name}' stopped successfully")
|
377
|
+
return status
|
378
|
+
|
379
|
+
async def get_vm_status(self, requestor_name: str) -> VMInfo:
|
380
|
+
"""Get current status of a VM.
|
381
|
+
|
382
|
+
Args:
|
383
|
+
requestor_name: Requestor's VM name
|
384
|
+
|
385
|
+
Returns:
|
386
|
+
VM status information
|
387
|
+
"""
|
388
|
+
try:
|
389
|
+
# Get multipass name from mapper
|
390
|
+
multipass_name = await self.name_mapper.get_multipass_name(requestor_name)
|
391
|
+
if not multipass_name:
|
392
|
+
raise VMNotFoundError(f"VM {requestor_name} not found")
|
393
|
+
|
394
|
+
# Get VM info from multipass
|
395
|
+
info = self._get_vm_info(multipass_name)
|
396
|
+
|
397
|
+
return VMInfo(
|
398
|
+
id=requestor_name, # Use requestor name as ID
|
399
|
+
name=requestor_name,
|
400
|
+
status=VMStatus(info.get("state", "unknown").lower()),
|
401
|
+
resources=VMResources(
|
402
|
+
cpu=int(info.get("cpu_count", 1)),
|
403
|
+
memory=int(info.get("memory_total", 1024) / 1024),
|
404
|
+
storage=int(info.get("disk_total", 10 * 1024) / 1024)
|
405
|
+
),
|
406
|
+
ip_address=info.get("ipv4", [None])[0],
|
407
|
+
ssh_port=self.proxy_manager.get_port(multipass_name)
|
408
|
+
)
|
409
|
+
except Exception as e:
|
410
|
+
logger.error(f"Error getting VM status: {e}")
|
411
|
+
return VMInfo(
|
412
|
+
id=requestor_name,
|
413
|
+
name=requestor_name,
|
414
|
+
status=VMStatus.ERROR,
|
415
|
+
resources=VMResources(cpu=1, memory=1, storage=10),
|
416
|
+
error_message=str(e)
|
417
|
+
)
|
418
|
+
|
419
|
+
async def add_ssh_key(self, vm_id: str, key: str) -> None:
|
420
|
+
"""Add SSH key to VM.
|
421
|
+
|
422
|
+
Args:
|
423
|
+
vm_id: VM identifier
|
424
|
+
key: SSH key to add
|
425
|
+
"""
|
426
|
+
# Not implemented - we use cloud-init for SSH key setup
|
427
|
+
pass
|
@@ -0,0 +1,108 @@
|
|
1
|
+
import asyncio
|
2
|
+
from typing import Optional, Dict
|
3
|
+
import json
|
4
|
+
from pathlib import Path
|
5
|
+
import logging
|
6
|
+
|
7
|
+
logger = logging.getLogger(__name__)
|
8
|
+
|
9
|
+
class VMNameMapper:
|
10
|
+
"""Maps between requestor VM names and multipass VM names."""
|
11
|
+
|
12
|
+
def __init__(self, storage_path: Optional[Path] = None):
|
13
|
+
"""Initialize name mapper.
|
14
|
+
|
15
|
+
Args:
|
16
|
+
storage_path: Optional path to persist mappings
|
17
|
+
"""
|
18
|
+
self._name_map: Dict[str, str] = {} # requestor_name -> multipass_name
|
19
|
+
self._reverse_map: Dict[str, str] = {} # multipass_name -> requestor_name
|
20
|
+
self._lock = asyncio.Lock()
|
21
|
+
self._storage_path = storage_path
|
22
|
+
|
23
|
+
# Load existing mappings if storage path provided
|
24
|
+
if storage_path and storage_path.exists():
|
25
|
+
try:
|
26
|
+
with open(storage_path) as f:
|
27
|
+
data = json.load(f)
|
28
|
+
self._name_map = data.get('name_map', {})
|
29
|
+
self._reverse_map = data.get('reverse_map', {})
|
30
|
+
logger.info(f"Loaded {len(self._name_map)} VM name mappings")
|
31
|
+
except Exception as e:
|
32
|
+
logger.error(f"Failed to load VM name mappings: {e}")
|
33
|
+
|
34
|
+
async def add_mapping(self, requestor_name: str, multipass_name: str) -> None:
|
35
|
+
"""Add a new name mapping.
|
36
|
+
|
37
|
+
Args:
|
38
|
+
requestor_name: Name used by requestor
|
39
|
+
multipass_name: Full multipass VM name
|
40
|
+
"""
|
41
|
+
async with self._lock:
|
42
|
+
self._name_map[requestor_name] = multipass_name
|
43
|
+
self._reverse_map[multipass_name] = requestor_name
|
44
|
+
await self._save_mappings()
|
45
|
+
logger.info(f"Added mapping: {requestor_name} -> {multipass_name}")
|
46
|
+
|
47
|
+
async def get_multipass_name(self, requestor_name: str) -> Optional[str]:
|
48
|
+
"""Get multipass name for a requestor name.
|
49
|
+
|
50
|
+
Args:
|
51
|
+
requestor_name: Name used by requestor
|
52
|
+
|
53
|
+
Returns:
|
54
|
+
Multipass VM name if found, None otherwise
|
55
|
+
"""
|
56
|
+
return self._name_map.get(requestor_name)
|
57
|
+
|
58
|
+
async def get_requestor_name(self, multipass_name: str) -> Optional[str]:
|
59
|
+
"""Get requestor name for a multipass name.
|
60
|
+
|
61
|
+
Args:
|
62
|
+
multipass_name: Full multipass VM name
|
63
|
+
|
64
|
+
Returns:
|
65
|
+
Requestor name if found, None otherwise
|
66
|
+
"""
|
67
|
+
return self._reverse_map.get(multipass_name)
|
68
|
+
|
69
|
+
async def remove_mapping(self, requestor_name: str) -> None:
|
70
|
+
"""Remove a name mapping.
|
71
|
+
|
72
|
+
Args:
|
73
|
+
requestor_name: Name used by requestor
|
74
|
+
"""
|
75
|
+
async with self._lock:
|
76
|
+
if requestor_name in self._name_map:
|
77
|
+
multipass_name = self._name_map[requestor_name]
|
78
|
+
del self._name_map[requestor_name]
|
79
|
+
del self._reverse_map[multipass_name]
|
80
|
+
await self._save_mappings()
|
81
|
+
logger.info(f"Removed mapping: {requestor_name} -> {multipass_name}")
|
82
|
+
|
83
|
+
async def _save_mappings(self) -> None:
|
84
|
+
"""Save mappings to storage if path provided."""
|
85
|
+
if self._storage_path:
|
86
|
+
try:
|
87
|
+
data = {
|
88
|
+
'name_map': self._name_map,
|
89
|
+
'reverse_map': self._reverse_map
|
90
|
+
}
|
91
|
+
# Create parent directories if they don't exist
|
92
|
+
self._storage_path.parent.mkdir(parents=True, exist_ok=True)
|
93
|
+
# Write to temporary file first
|
94
|
+
temp_path = self._storage_path.with_suffix('.tmp')
|
95
|
+
with open(temp_path, 'w') as f:
|
96
|
+
json.dump(data, f, indent=2)
|
97
|
+
# Rename temporary file to actual file (atomic operation)
|
98
|
+
temp_path.rename(self._storage_path)
|
99
|
+
except Exception as e:
|
100
|
+
logger.error(f"Failed to save VM name mappings: {e}")
|
101
|
+
|
102
|
+
def list_mappings(self) -> Dict[str, str]:
|
103
|
+
"""Get all current name mappings.
|
104
|
+
|
105
|
+
Returns:
|
106
|
+
Dictionary of requestor_name -> multipass_name mappings
|
107
|
+
"""
|
108
|
+
return dict(self._name_map)
|