golem-vm-provider 0.1.26__py3-none-any.whl → 0.1.28__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.
@@ -100,10 +100,8 @@ class PortVerificationDisplay:
100
100
  print("-------------------------")
101
101
 
102
102
  if self.skip_verification:
103
- print("\nAll ports available in development mode")
104
- print(f"└─ Port Range: {self.port_range_start}-{self.port_range_end}")
105
- print("└─ Status: Port verification skipped")
106
- print("└─ Note: Configure ports before deploying to production")
103
+ print("✅ Development Mode: Port verification skipped")
104
+ print(f"└─ Port Range: {self.port_range_start}-{self.port_range_end} assumed available")
107
105
  return
108
106
 
109
107
  await self.animate_verification("Scanning VM access ports...")
@@ -209,10 +207,10 @@ class PortVerificationDisplay:
209
207
  print("\n🎯 Current Status:", end=" ")
210
208
 
211
209
  if self.skip_verification:
212
- print("Development Mode")
213
- print("└─ Status: Port verification skipped")
214
- print(f"└─ Available: All ports in range {self.port_range_start}-{self.port_range_end}")
215
- print("└─ Note: This is for development only, configure ports in production")
210
+ print("Development Mode")
211
+ print("└─ Status: Local port verification complete")
212
+ print(f"└─ Available: All ports in range {self.port_range_start}-{self.port_range_end} are assumed to be available locally")
213
+ print("└─ Note: External accessibility is not checked in dev mode")
216
214
  return
217
215
 
218
216
  if discovery_result is None:
provider/utils/retry.py CHANGED
@@ -46,3 +46,42 @@ def async_retry(
46
46
  raise last_exception
47
47
  return wrapper
48
48
  return decorator
49
+
50
+ def async_retry_unless_not_found(
51
+ retries: int = 3,
52
+ delay: float = 1.0,
53
+ backoff: float = 2.0,
54
+ exceptions: tuple = (Exception,)
55
+ ) -> Callable[[Callable[..., T]], Callable[..., T]]:
56
+ """
57
+ Retry decorator for async functions with exponential backoff, but skips retries for VMNotFoundError.
58
+ """
59
+ from ..vm.models import VMNotFoundError
60
+
61
+ def decorator(func: Callable[..., T]) -> Callable[..., T]:
62
+ @wraps(func)
63
+ async def wrapper(*args: Any, **kwargs: Any) -> T:
64
+ current_delay = delay
65
+ last_exception = None
66
+
67
+ for attempt in range(retries + 1):
68
+ try:
69
+ return await func(*args, **kwargs)
70
+ except exceptions as e:
71
+ if isinstance(e, VMNotFoundError):
72
+ raise e
73
+
74
+ last_exception = e
75
+ if attempt == retries:
76
+ break
77
+
78
+ logger.warning(
79
+ f"Attempt {attempt + 1}/{retries} failed for {func.__name__}: {e}. "
80
+ f"Retrying in {current_delay:.1f}s..."
81
+ )
82
+ await asyncio.sleep(current_delay)
83
+ current_delay *= backoff
84
+
85
+ raise last_exception
86
+ return wrapper
87
+ return decorator
provider/vm/__init__.py CHANGED
@@ -12,7 +12,7 @@ from .models import (
12
12
  VMStateError,
13
13
  ResourceError
14
14
  )
15
- from .multipass import MultipassProvider
15
+ from .multipass_adapter import MultipassAdapter
16
16
 
17
17
  __all__ = [
18
18
  "VMConfig",
provider/vm/models.py CHANGED
@@ -1,5 +1,5 @@
1
1
  from enum import Enum
2
- from pydantic import BaseModel, Field, validator
2
+ from pydantic import BaseModel, Field, field_validator
3
3
  from typing import Dict, Optional
4
4
  from datetime import datetime
5
5
 
@@ -28,14 +28,14 @@ class VMResources(BaseModel):
28
28
  memory: int = Field(..., ge=1, description="Memory in GB")
29
29
  storage: int = Field(..., ge=10, description="Storage in GB")
30
30
 
31
- @validator("cpu")
31
+ @field_validator("cpu")
32
32
  def validate_cpu(cls, v: int) -> int:
33
33
  """Validate CPU cores."""
34
34
  if v not in [1, 2, 4, 8, 16]:
35
35
  raise ValueError("CPU cores must be 1, 2, 4, 8, or 16")
36
36
  return v
37
37
 
38
- @validator("memory")
38
+ @field_validator("memory")
39
39
  def validate_memory(cls, v: int) -> int:
40
40
  """Validate memory."""
41
41
  if v not in [1, 2, 4, 8, 16, 32, 64]:
@@ -66,21 +66,21 @@ class VMCreateRequest(BaseModel):
66
66
  ssh_key: str = Field(..., pattern="^(ssh-rsa|ssh-ed25519) ",
67
67
  description="SSH public key for VM access")
68
68
 
69
- @validator("name")
69
+ @field_validator("name")
70
70
  def validate_name(cls, v: str) -> str:
71
71
  """Validate VM name."""
72
72
  if "--" in v:
73
73
  raise ValueError("VM name cannot contain consecutive hyphens")
74
74
  return v
75
75
 
76
- @validator("cpu_cores")
76
+ @field_validator("cpu_cores")
77
77
  def validate_cpu(cls, v: Optional[int]) -> Optional[int]:
78
78
  """Validate CPU cores."""
79
79
  if v is not None and v not in [1, 2, 4, 8, 16]:
80
80
  raise ValueError("CPU cores must be 1, 2, 4, 8, or 16")
81
81
  return v
82
82
 
83
- @validator("memory_gb")
83
+ @field_validator("memory_gb")
84
84
  def validate_memory(cls, v: Optional[int]) -> Optional[int]:
85
85
  """Validate memory."""
86
86
  if v is not None and v not in [1, 2, 4, 8, 16, 32, 64]:
@@ -97,8 +97,9 @@ class VMConfig(BaseModel):
97
97
  size: Optional[VMSize] = None
98
98
  ssh_key: str = Field(..., pattern="^(ssh-rsa|ssh-ed25519) ",
99
99
  description="SSH public key for VM access")
100
+ cloud_init_path: Optional[str] = None
100
101
 
101
- @validator("name")
102
+ @field_validator("name")
102
103
  def validate_name(cls, v: str) -> str:
103
104
  """Validate VM name."""
104
105
  if "--" in v:
provider/vm/multipass.py CHANGED
@@ -15,423 +15,5 @@ from .name_mapper import VMNameMapper
15
15
  logger = setup_logger(__name__)
16
16
 
17
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.name_mapper = VMNameMapper(self.vm_data_dir / "vm_names.json")
41
- self.proxy_manager = PythonProxyManager(
42
- port_manager=port_manager,
43
- name_mapper=self.name_mapper
44
- )
45
-
46
- def _verify_installation(self) -> None:
47
- """Verify multipass is installed and get version."""
48
- try:
49
- result = subprocess.run(
50
- [self.multipass_path, "version"],
51
- capture_output=True,
52
- text=True,
53
- check=True
54
- )
55
- logger.info(f"🔧 Using Multipass version: {result.stdout.strip()}")
56
- except subprocess.CalledProcessError as e:
57
- raise MultipassError(
58
- f"Failed to verify multipass installation: {e.stderr}")
59
- except FileNotFoundError:
60
- raise MultipassError(
61
- f"Multipass not found at {self.multipass_path}")
62
-
63
- def _get_all_vms_resources(self) -> Dict[str, VMResources]:
64
- """Get resources for all running VMs from multipass.
65
-
66
- Returns:
67
- Dictionary mapping VM names to their resources
68
- """
69
- result = self._run_multipass(["list", "--format", "json"])
70
- data = json.loads(result.stdout)
71
- vm_resources = {}
72
-
73
- for vm in data.get("list", []):
74
- if vm.get("name", "").startswith("golem-"):
75
- try:
76
- info = self._get_vm_info(vm["name"])
77
- vm_resources[vm["name"]] = VMResources(
78
- cpu=int(info.get("cpu_count", 1)),
79
- memory=int(info.get("memory_total", 1024) / 1024),
80
- storage=int(info.get("disk_total", 10 * 1024) / 1024)
81
- )
82
- except Exception as e:
83
- logger.error(f"Failed to get info for VM {vm['name']}: {e}")
84
- continue
85
-
86
- return vm_resources
87
-
88
- async def initialize(self) -> None:
89
- """Initialize the provider."""
90
- self._verify_installation()
91
-
92
- # Create SSH key directory
93
- ssh_key_dir = Path(settings.SSH_KEY_DIR)
94
- ssh_key_dir.mkdir(parents=True, exist_ok=True)
95
-
96
- # Sync resource tracker with actual multipass state
97
- logger.info("🔄 Syncing resource tracker with multipass state...")
98
- vm_resources = self._get_all_vms_resources()
99
- await self.resource_tracker.sync_with_multipass(vm_resources)
100
- logger.info("✨ Resource tracker synced with multipass state")
101
-
102
- def _run_multipass(self, args: List[str], check: bool = True) -> subprocess.CompletedProcess:
103
- """Run a multipass command.
104
-
105
- Args:
106
- args: Command arguments
107
- check: Whether to check return code
108
-
109
- Returns:
110
- CompletedProcess instance
111
- """
112
- try:
113
- return subprocess.run(
114
- [self.multipass_path, *args],
115
- capture_output=True,
116
- text=True,
117
- check=check
118
- )
119
- except subprocess.CalledProcessError as e:
120
- raise MultipassError(f"Multipass command failed: {e.stderr}")
121
-
122
- def _get_vm_info(self, vm_id: str) -> Dict:
123
- """Get detailed information about a VM.
124
-
125
- Args:
126
- vm_id: VM identifier
127
-
128
- Returns:
129
- Dictionary with VM information
130
- """
131
- result = self._run_multipass(["info", vm_id, "--format", "json"])
132
- try:
133
- info = json.loads(result.stdout)
134
- return info["info"][vm_id]
135
- except (json.JSONDecodeError, KeyError) as e:
136
- raise MultipassError(f"Failed to parse VM info: {e}")
137
-
138
- def _get_vm_ip(self, vm_id: str) -> Optional[str]:
139
- """Get IP address of a VM.
140
-
141
- Args:
142
- vm_id: VM identifier
143
-
144
- Returns:
145
- IP address or None if not found
146
- """
147
- try:
148
- info = self._get_vm_info(vm_id)
149
- return info.get("ipv4", [None])[0]
150
- except Exception:
151
- return None
152
-
153
- async def create_vm(self, config: VMConfig) -> VMInfo:
154
- """Create a new VM.
155
-
156
- Args:
157
- config: VM configuration
158
-
159
- Returns:
160
- Information about the created VM
161
- """
162
- multipass_name = f"golem-{config.name}-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
163
- await self.name_mapper.add_mapping(config.name, multipass_name)
164
- cloud_init_path = None
165
- config_id = None
166
-
167
- # Verify resources are properly allocated
168
- if not self.resource_tracker.can_accept_resources(config.resources):
169
- raise VMCreateError("Resources not properly allocated or insufficient")
170
-
171
- try:
172
- # Generate cloud-init config with requestor's public key
173
- cloud_init_path, config_id = generate_cloud_init(
174
- hostname=config.name,
175
- ssh_key=config.ssh_key
176
- )
177
-
178
- # Launch VM
179
- logger.process(f"🚀 Launching VM {multipass_name} with config {config_id}")
180
- launch_cmd = [
181
- "launch",
182
- config.image,
183
- "--name", multipass_name,
184
- "--cloud-init", cloud_init_path,
185
- "--cpus", str(config.resources.cpu),
186
- "--memory", f"{config.resources.memory}G",
187
- "--disk", f"{config.resources.storage}G"
188
- ]
189
- self._run_multipass(launch_cmd)
190
-
191
- # Get VM IP
192
- ip_address = self._get_vm_ip(multipass_name)
193
- if not ip_address:
194
- raise MultipassError("Failed to get VM IP address")
195
-
196
- # Allocate port and configure proxy
197
- try:
198
- # First allocate a verified port
199
- ssh_port = self.port_manager.allocate_port(multipass_name)
200
- if not ssh_port:
201
- if settings.DEV_MODE:
202
- logger.warning("Failed to allocate verified SSH port in dev mode, falling back to random port")
203
- ssh_port = 0 # Let the proxy manager pick a random port
204
- else:
205
- raise MultipassError("Failed to allocate verified SSH port")
206
-
207
- # Then configure proxy with allocated port
208
- success = await self.proxy_manager.add_vm(multipass_name, ip_address, port=ssh_port)
209
- if not success:
210
- # Clean up allocated port if proxy fails
211
- self.port_manager.deallocate_port(multipass_name)
212
- raise MultipassError("Failed to configure proxy")
213
-
214
- # Create VM info and register with resource tracker
215
- vm_info = VMInfo(
216
- id=config.name, # Use requestor name as VM ID
217
- name=config.name,
218
- status=VMStatus.RUNNING,
219
- resources=config.resources,
220
- ip_address=ip_address,
221
- ssh_port=ssh_port
222
- )
223
-
224
- # Update resource tracker with VM ID
225
- await self.resource_tracker.allocate(config.resources, config.name)
226
-
227
- return vm_info
228
-
229
- except Exception as e:
230
- # If proxy configuration fails, ensure we cleanup the VM and resources
231
- self._run_multipass(["delete", multipass_name, "--purge"], check=False)
232
- await self.resource_tracker.deallocate(config.resources, config.name)
233
- await self.name_mapper.remove_mapping(config.name)
234
- raise VMCreateError(
235
- f"Failed to configure VM networking: {str(e)}", vm_id=config.name)
236
-
237
- except Exception as e:
238
- # Cleanup on failure (this catches VM creation errors)
239
- try:
240
- await self.delete_vm(config.name)
241
- except Exception as cleanup_error:
242
- logger.error(f"Error during VM cleanup: {cleanup_error}")
243
- # Ensure resources are deallocated even if delete_vm fails
244
- await self.resource_tracker.deallocate(config.resources, config.name)
245
- raise VMCreateError(f"Failed to create VM: {str(e)}", vm_id=config.name)
246
-
247
- finally:
248
- # Cleanup cloud-init file
249
- if cloud_init_path and config_id:
250
- cleanup_cloud_init(cloud_init_path, config_id)
251
-
252
- def _verify_vm_exists(self, vm_id: str) -> bool:
253
- """Check if VM exists in multipass.
254
-
255
- Args:
256
- vm_id: VM identifier
257
-
258
- Returns:
259
- True if VM exists, False otherwise
260
- """
261
- try:
262
- result = self._run_multipass(["list", "--format", "json"])
263
- data = json.loads(result.stdout)
264
- vms = data.get("list", [])
265
- return any(vm.get("name") == vm_id for vm in vms)
266
- except Exception:
267
- return False
268
-
269
- async def delete_vm(self, requestor_name: str) -> None:
270
- """Delete a VM.
271
-
272
- Args:
273
- requestor_name: Requestor's VM name
274
- """
275
- # Get multipass name from mapper
276
- multipass_name = await self.name_mapper.get_multipass_name(requestor_name)
277
- if not multipass_name:
278
- logger.warning(f"No multipass name found for VM {requestor_name}")
279
- return
280
-
281
- logger.process(f"🗑️ Initiating deletion of VM {multipass_name}")
282
-
283
- # Get VM info for resource deallocation
284
- try:
285
- vm_info = await self.get_vm_status(requestor_name)
286
- except Exception as e:
287
- logger.error(f"Failed to get VM info for cleanup: {e}")
288
- vm_info = None
289
-
290
- # Check if VM exists
291
- if not self._verify_vm_exists(multipass_name):
292
- logger.warning(f"VM {multipass_name} not found in multipass")
293
- else:
294
- try:
295
- # First mark for deletion
296
- logger.info("🔄 Marking VM for deletion...")
297
- self._run_multipass(["delete", multipass_name], check=False)
298
-
299
- # Then purge
300
- logger.info("🔄 Purging deleted VM...")
301
- self._run_multipass(["purge"], check=False)
302
-
303
- # Verify deletion
304
- if self._verify_vm_exists(multipass_name):
305
- logger.error(f"VM {multipass_name} still exists after deletion attempt")
306
- # Try one more time with force
307
- logger.info("🔄 Attempting forced deletion...")
308
- self._run_multipass(["stop", "--all", multipass_name], check=False)
309
- self._run_multipass(["delete", "--purge", multipass_name], check=False)
310
- if self._verify_vm_exists(multipass_name):
311
- raise MultipassError(f"Failed to delete VM {multipass_name}")
312
-
313
- logger.success("✨ VM instance removed")
314
- except Exception as e:
315
- logger.error(f"Error deleting VM {multipass_name} from multipass: {e}")
316
- raise
317
-
318
- # Clean up proxy config and port allocation
319
- try:
320
- logger.info("🔄 Cleaning up network configuration...")
321
- await self.proxy_manager.remove_vm(multipass_name)
322
- self.port_manager.deallocate_port(multipass_name)
323
- logger.success("✨ Network configuration cleaned up")
324
- except Exception as e:
325
- logger.error(f"Error cleaning up network configuration for VM {multipass_name}: {e}")
326
-
327
- # Deallocate resources
328
- if vm_info and vm_info.resources:
329
- try:
330
- logger.info("🔄 Deallocating resources...")
331
- await self.resource_tracker.deallocate(vm_info.resources, requestor_name)
332
- logger.success("✨ Resources deallocated")
333
- except Exception as e:
334
- logger.error(f"Error deallocating resources: {e}")
335
-
336
- # Remove name mapping
337
- try:
338
- await self.name_mapper.remove_mapping(requestor_name)
339
- logger.success("✨ Name mapping removed")
340
- except Exception as e:
341
- logger.error(f"Error removing name mapping: {e}")
342
-
343
- # Sync resource tracker with actual state
344
- logger.info("🔄 Syncing resource tracker with multipass state...")
345
- vm_resources = self._get_all_vms_resources()
346
- await self.resource_tracker.sync_with_multipass(vm_resources)
347
- logger.info("✨ Resource tracker synced with multipass state")
348
-
349
- async def start_vm(self, requestor_name: str) -> VMInfo:
350
- """Start a VM.
351
-
352
- Args:
353
- requestor_name: Requestor's VM name
354
-
355
- Returns:
356
- Updated VM information
357
- """
358
- # Get multipass name from mapper
359
- multipass_name = await self.name_mapper.get_multipass_name(requestor_name)
360
- if not multipass_name:
361
- raise VMNotFoundError(f"VM {requestor_name} not found")
362
-
363
- logger.process(f"🔄 Starting VM '{requestor_name}'")
364
- self._run_multipass(["start", multipass_name])
365
- status = await self.get_vm_status(requestor_name)
366
- logger.success(f"✨ VM '{requestor_name}' started successfully")
367
- return status
368
-
369
- async def stop_vm(self, requestor_name: str) -> VMInfo:
370
- """Stop a VM.
371
-
372
- Args:
373
- requestor_name: Requestor's VM name
374
-
375
- Returns:
376
- Updated VM information
377
- """
378
- # Get multipass name from mapper
379
- multipass_name = await self.name_mapper.get_multipass_name(requestor_name)
380
- if not multipass_name:
381
- raise VMNotFoundError(f"VM {requestor_name} not found")
382
-
383
- logger.process(f"🔄 Stopping VM '{requestor_name}'")
384
- self._run_multipass(["stop", multipass_name])
385
- status = await self.get_vm_status(requestor_name)
386
- logger.success(f"✨ VM '{requestor_name}' stopped successfully")
387
- return status
388
-
389
- async def get_vm_status(self, requestor_name: str) -> VMInfo:
390
- """Get current status of a VM.
391
-
392
- Args:
393
- requestor_name: Requestor's VM name
394
-
395
- Returns:
396
- VM status information
397
- """
398
- try:
399
- # Get multipass name from mapper
400
- multipass_name = await self.name_mapper.get_multipass_name(requestor_name)
401
- if not multipass_name:
402
- raise VMNotFoundError(f"VM {requestor_name} not found")
403
-
404
- # Get VM info from multipass
405
- info = self._get_vm_info(multipass_name)
406
-
407
- return VMInfo(
408
- id=requestor_name, # Use requestor name as ID
409
- name=requestor_name,
410
- status=VMStatus(info.get("state", "unknown").lower()),
411
- resources=VMResources(
412
- cpu=int(info.get("cpu_count", 1)),
413
- memory=int(info.get("memory_total", 1024) / 1024),
414
- storage=int(info.get("disk_total", 10 * 1024) / 1024)
415
- ),
416
- ip_address=info.get("ipv4", [None])[0],
417
- ssh_port=self.proxy_manager.get_port(multipass_name)
418
- )
419
- except Exception as e:
420
- logger.error(f"Error getting VM status: {e}")
421
- return VMInfo(
422
- id=requestor_name,
423
- name=requestor_name,
424
- status=VMStatus.ERROR,
425
- resources=VMResources(cpu=1, memory=1, storage=10),
426
- error_message=str(e)
427
- )
428
-
429
- async def add_ssh_key(self, vm_id: str, key: str) -> None:
430
- """Add SSH key to VM.
431
-
432
- Args:
433
- vm_id: VM identifier
434
- key: SSH key to add
435
- """
436
- # Not implemented - we use cloud-init for SSH key setup
437
- pass
18
+ from .service import VMService
19
+ from .multipass_adapter import MultipassAdapter