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.
@@ -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)