golem-vm-provider 0.1.24__py3-none-any.whl → 0.1.27__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.
@@ -10,17 +10,25 @@ class PortVerificationDisplay:
10
10
 
11
11
  SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
12
12
 
13
- def __init__(self, provider_port: int, port_range_start: int, port_range_end: int):
13
+ def __init__(
14
+ self,
15
+ provider_port: int,
16
+ port_range_start: int,
17
+ port_range_end: int,
18
+ skip_verification: bool = False
19
+ ):
14
20
  """Initialize the display.
15
21
 
16
22
  Args:
17
23
  provider_port: Port used for provider access
18
24
  port_range_start: Start of VM access port range
19
25
  port_range_end: End of VM access port range
26
+ skip_verification: Whether port verification is skipped (development mode)
20
27
  """
21
28
  self.provider_port = provider_port
22
29
  self.port_range_start = port_range_start
23
30
  self.port_range_end = port_range_end
31
+ self.skip_verification = skip_verification
24
32
  self.spinner_idx = 0
25
33
 
26
34
  def _update_spinner(self):
@@ -91,6 +99,11 @@ class PortVerificationDisplay:
91
99
  print("\n🔒 VM Access Ports (Required)")
92
100
  print("-------------------------")
93
101
 
102
+ if self.skip_verification:
103
+ print("✅ Development Mode: Port verification skipped")
104
+ print(f"└─ Port Range: {self.port_range_start}-{self.port_range_end} assumed available")
105
+ return
106
+
94
107
  await self.animate_verification("Scanning VM access ports...")
95
108
 
96
109
  # Calculate progress
@@ -125,6 +138,9 @@ class PortVerificationDisplay:
125
138
  discovery_result: Verification result for discovery port
126
139
  ssh_results: Dictionary mapping SSH ports to their verification results
127
140
  """
141
+ if self.skip_verification:
142
+ return
143
+
128
144
  issues = []
129
145
 
130
146
  # Check discovery port
@@ -155,6 +171,9 @@ class PortVerificationDisplay:
155
171
  discovery_result: Verification result for discovery port
156
172
  ssh_results: Dictionary mapping SSH ports to their verification results
157
173
  """
174
+ if self.skip_verification:
175
+ return
176
+
158
177
  # Check if we have any issues
159
178
  has_issues = (
160
179
  not discovery_result.accessible or
@@ -186,6 +205,13 @@ class PortVerificationDisplay:
186
205
  ssh_results: Dictionary mapping SSH ports to their verification results
187
206
  """
188
207
  print("\n🎯 Current Status:", end=" ")
208
+
209
+ if self.skip_verification:
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")
214
+ return
189
215
 
190
216
  if discovery_result is None:
191
217
  print("Verification Failed")
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]:
@@ -57,30 +57,30 @@ class VMResources(BaseModel):
57
57
  class VMCreateRequest(BaseModel):
58
58
  """Request to create a new VM."""
59
59
  name: str = Field(..., min_length=3, max_length=64,
60
- regex="^[a-z0-9][a-z0-9-]*[a-z0-9]$")
60
+ pattern="^[a-z0-9][a-z0-9-]*[a-z0-9]$")
61
61
  size: Optional[VMSize] = None
62
62
  cpu_cores: Optional[int] = None
63
63
  memory_gb: Optional[int] = None
64
64
  storage_gb: Optional[int] = None
65
65
  image: Optional[str] = Field(default="24.04") # Ubuntu 24.04 LTS
66
- ssh_key: str = Field(..., regex="^(ssh-rsa|ssh-ed25519) ",
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]:
@@ -91,14 +91,15 @@ class VMCreateRequest(BaseModel):
91
91
  class VMConfig(BaseModel):
92
92
  """VM configuration."""
93
93
  name: str = Field(..., min_length=3, max_length=64,
94
- regex="^[a-z0-9][a-z0-9-]*[a-z0-9]$")
94
+ pattern="^[a-z0-9][a-z0-9-]*[a-z0-9]$")
95
95
  resources: VMResources
96
96
  image: str = Field(default="24.04") # Ubuntu 24.04 LTS
97
97
  size: Optional[VMSize] = None
98
- ssh_key: str = Field(..., regex="^(ssh-rsa|ssh-ed25519) ",
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:
@@ -127,7 +128,7 @@ class VMInfo(BaseModel):
127
128
  class SSHKey(BaseModel):
128
129
  """SSH key information."""
129
130
  name: str = Field(..., min_length=1, max_length=64)
130
- public_key: str = Field(..., regex="^(ssh-rsa|ssh-ed25519) ")
131
+ public_key: str = Field(..., pattern="^(ssh-rsa|ssh-ed25519) ")
131
132
  fingerprint: Optional[str] = None
132
133
 
133
134
 
provider/vm/multipass.py CHANGED
@@ -15,419 +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
- raise MultipassError("Failed to allocate verified SSH port")
202
-
203
- # Then configure proxy with allocated port
204
- success = await self.proxy_manager.add_vm(multipass_name, ip_address, port=ssh_port)
205
- if not success:
206
- # Clean up allocated port if proxy fails
207
- self.port_manager.deallocate_port(multipass_name)
208
- raise MultipassError("Failed to configure proxy")
209
-
210
- # Create VM info and register with resource tracker
211
- vm_info = VMInfo(
212
- id=config.name, # Use requestor name as VM ID
213
- name=config.name,
214
- status=VMStatus.RUNNING,
215
- resources=config.resources,
216
- ip_address=ip_address,
217
- ssh_port=ssh_port
218
- )
219
-
220
- # Update resource tracker with VM ID
221
- await self.resource_tracker.allocate(config.resources, config.name)
222
-
223
- return vm_info
224
-
225
- except Exception as e:
226
- # If proxy configuration fails, ensure we cleanup the VM and resources
227
- self._run_multipass(["delete", multipass_name, "--purge"], check=False)
228
- await self.resource_tracker.deallocate(config.resources, config.name)
229
- await self.name_mapper.remove_mapping(config.name)
230
- raise VMCreateError(
231
- f"Failed to configure VM networking: {str(e)}", vm_id=config.name)
232
-
233
- except Exception as e:
234
- # Cleanup on failure (this catches VM creation errors)
235
- try:
236
- await self.delete_vm(config.name)
237
- except Exception as cleanup_error:
238
- logger.error(f"Error during VM cleanup: {cleanup_error}")
239
- # Ensure resources are deallocated even if delete_vm fails
240
- await self.resource_tracker.deallocate(config.resources, config.name)
241
- raise VMCreateError(f"Failed to create VM: {str(e)}", vm_id=config.name)
242
-
243
- finally:
244
- # Cleanup cloud-init file
245
- if cloud_init_path and config_id:
246
- cleanup_cloud_init(cloud_init_path, config_id)
247
-
248
- def _verify_vm_exists(self, vm_id: str) -> bool:
249
- """Check if VM exists in multipass.
250
-
251
- Args:
252
- vm_id: VM identifier
253
-
254
- Returns:
255
- True if VM exists, False otherwise
256
- """
257
- try:
258
- result = self._run_multipass(["list", "--format", "json"])
259
- data = json.loads(result.stdout)
260
- vms = data.get("list", [])
261
- return any(vm.get("name") == vm_id for vm in vms)
262
- except Exception:
263
- return False
264
-
265
- async def delete_vm(self, requestor_name: str) -> None:
266
- """Delete a VM.
267
-
268
- Args:
269
- requestor_name: Requestor's VM name
270
- """
271
- # Get multipass name from mapper
272
- multipass_name = await self.name_mapper.get_multipass_name(requestor_name)
273
- if not multipass_name:
274
- logger.warning(f"No multipass name found for VM {requestor_name}")
275
- return
276
-
277
- logger.process(f"🗑️ Initiating deletion of VM {multipass_name}")
278
-
279
- # Get VM info for resource deallocation
280
- try:
281
- vm_info = await self.get_vm_status(requestor_name)
282
- except Exception as e:
283
- logger.error(f"Failed to get VM info for cleanup: {e}")
284
- vm_info = None
285
-
286
- # Check if VM exists
287
- if not self._verify_vm_exists(multipass_name):
288
- logger.warning(f"VM {multipass_name} not found in multipass")
289
- else:
290
- try:
291
- # First mark for deletion
292
- logger.info("🔄 Marking VM for deletion...")
293
- self._run_multipass(["delete", multipass_name], check=False)
294
-
295
- # Then purge
296
- logger.info("🔄 Purging deleted VM...")
297
- self._run_multipass(["purge"], check=False)
298
-
299
- # Verify deletion
300
- if self._verify_vm_exists(multipass_name):
301
- logger.error(f"VM {multipass_name} still exists after deletion attempt")
302
- # Try one more time with force
303
- logger.info("🔄 Attempting forced deletion...")
304
- self._run_multipass(["stop", "--all", multipass_name], check=False)
305
- self._run_multipass(["delete", "--purge", multipass_name], check=False)
306
- if self._verify_vm_exists(multipass_name):
307
- raise MultipassError(f"Failed to delete VM {multipass_name}")
308
-
309
- logger.success("✨ VM instance removed")
310
- except Exception as e:
311
- logger.error(f"Error deleting VM {multipass_name} from multipass: {e}")
312
- raise
313
-
314
- # Clean up proxy config and port allocation
315
- try:
316
- logger.info("🔄 Cleaning up network configuration...")
317
- await self.proxy_manager.remove_vm(multipass_name)
318
- self.port_manager.deallocate_port(multipass_name)
319
- logger.success("✨ Network configuration cleaned up")
320
- except Exception as e:
321
- logger.error(f"Error cleaning up network configuration for VM {multipass_name}: {e}")
322
-
323
- # Deallocate resources
324
- if vm_info and vm_info.resources:
325
- try:
326
- logger.info("🔄 Deallocating resources...")
327
- await self.resource_tracker.deallocate(vm_info.resources, requestor_name)
328
- logger.success("✨ Resources deallocated")
329
- except Exception as e:
330
- logger.error(f"Error deallocating resources: {e}")
331
-
332
- # Remove name mapping
333
- try:
334
- await self.name_mapper.remove_mapping(requestor_name)
335
- logger.success("✨ Name mapping removed")
336
- except Exception as e:
337
- logger.error(f"Error removing name mapping: {e}")
338
-
339
- # Sync resource tracker with actual state
340
- logger.info("🔄 Syncing resource tracker with multipass state...")
341
- vm_resources = self._get_all_vms_resources()
342
- await self.resource_tracker.sync_with_multipass(vm_resources)
343
- logger.info("✨ Resource tracker synced with multipass state")
344
-
345
- async def start_vm(self, requestor_name: str) -> VMInfo:
346
- """Start a VM.
347
-
348
- Args:
349
- requestor_name: Requestor's VM name
350
-
351
- Returns:
352
- Updated VM information
353
- """
354
- # Get multipass name from mapper
355
- multipass_name = await self.name_mapper.get_multipass_name(requestor_name)
356
- if not multipass_name:
357
- raise VMNotFoundError(f"VM {requestor_name} not found")
358
-
359
- logger.process(f"🔄 Starting VM '{requestor_name}'")
360
- self._run_multipass(["start", multipass_name])
361
- status = await self.get_vm_status(requestor_name)
362
- logger.success(f"✨ VM '{requestor_name}' started successfully")
363
- return status
364
-
365
- async def stop_vm(self, requestor_name: str) -> VMInfo:
366
- """Stop a VM.
367
-
368
- Args:
369
- requestor_name: Requestor's VM name
370
-
371
- Returns:
372
- Updated VM information
373
- """
374
- # Get multipass name from mapper
375
- multipass_name = await self.name_mapper.get_multipass_name(requestor_name)
376
- if not multipass_name:
377
- raise VMNotFoundError(f"VM {requestor_name} not found")
378
-
379
- logger.process(f"🔄 Stopping VM '{requestor_name}'")
380
- self._run_multipass(["stop", multipass_name])
381
- status = await self.get_vm_status(requestor_name)
382
- logger.success(f"✨ VM '{requestor_name}' stopped successfully")
383
- return status
384
-
385
- async def get_vm_status(self, requestor_name: str) -> VMInfo:
386
- """Get current status of a VM.
387
-
388
- Args:
389
- requestor_name: Requestor's VM name
390
-
391
- Returns:
392
- VM status information
393
- """
394
- try:
395
- # Get multipass name from mapper
396
- multipass_name = await self.name_mapper.get_multipass_name(requestor_name)
397
- if not multipass_name:
398
- raise VMNotFoundError(f"VM {requestor_name} not found")
399
-
400
- # Get VM info from multipass
401
- info = self._get_vm_info(multipass_name)
402
-
403
- return VMInfo(
404
- id=requestor_name, # Use requestor name as ID
405
- name=requestor_name,
406
- status=VMStatus(info.get("state", "unknown").lower()),
407
- resources=VMResources(
408
- cpu=int(info.get("cpu_count", 1)),
409
- memory=int(info.get("memory_total", 1024) / 1024),
410
- storage=int(info.get("disk_total", 10 * 1024) / 1024)
411
- ),
412
- ip_address=info.get("ipv4", [None])[0],
413
- ssh_port=self.proxy_manager.get_port(multipass_name)
414
- )
415
- except Exception as e:
416
- logger.error(f"Error getting VM status: {e}")
417
- return VMInfo(
418
- id=requestor_name,
419
- name=requestor_name,
420
- status=VMStatus.ERROR,
421
- resources=VMResources(cpu=1, memory=1, storage=10),
422
- error_message=str(e)
423
- )
424
-
425
- async def add_ssh_key(self, vm_id: str, key: str) -> None:
426
- """Add SSH key to VM.
427
-
428
- Args:
429
- vm_id: VM identifier
430
- key: SSH key to add
431
- """
432
- # Not implemented - we use cloud-init for SSH key setup
433
- pass
18
+ from .service import VMService
19
+ from .multipass_adapter import MultipassAdapter