cua-computer 0.1.29__py3-none-any.whl → 0.2.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,541 @@
1
+ """Lume VM provider implementation using curl commands.
2
+
3
+ This provider uses direct curl commands to interact with the Lume API,
4
+ removing the dependency on the pylume Python package.
5
+ """
6
+
7
+ import os
8
+ import re
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import subprocess
13
+ import urllib.parse
14
+ from typing import Dict, Any, Optional, List, Tuple
15
+
16
+ from ..base import BaseVMProvider, VMProviderType
17
+ from ...logger import Logger, LogLevel
18
+ from ..lume_api import (
19
+ lume_api_get,
20
+ lume_api_run,
21
+ lume_api_stop,
22
+ lume_api_update,
23
+ lume_api_pull,
24
+ HAS_CURL,
25
+ parse_memory
26
+ )
27
+
28
+ # Setup logging
29
+ logger = logging.getLogger(__name__)
30
+
31
+ class LumeProvider(BaseVMProvider):
32
+ """Lume VM provider implementation using direct curl commands.
33
+
34
+ This provider uses curl to interact with the Lume API server,
35
+ removing the dependency on the pylume Python package.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ port: int = 7777,
41
+ host: str = "localhost",
42
+ storage: Optional[str] = None,
43
+ verbose: bool = False,
44
+ ephemeral: bool = False,
45
+ ):
46
+ """Initialize the Lume provider.
47
+
48
+ Args:
49
+ port: Port for the Lume API server (default: 7777)
50
+ host: Host to use for API connections (default: localhost)
51
+ storage: Path to store VM data
52
+ verbose: Enable verbose logging
53
+ """
54
+ if not HAS_CURL:
55
+ raise ImportError(
56
+ "curl is required for LumeProvider. "
57
+ "Please ensure it is installed and in your PATH."
58
+ )
59
+
60
+ self.host = host
61
+ self.port = port # Default port for Lume API
62
+ self.storage = storage
63
+ self.verbose = verbose
64
+ self.ephemeral = ephemeral # If True, VMs will be deleted after stopping
65
+
66
+ # Base API URL for Lume API calls
67
+ self.api_base_url = f"http://{self.host}:{self.port}"
68
+
69
+ self.logger = logging.getLogger(__name__)
70
+
71
+ @property
72
+ def provider_type(self) -> VMProviderType:
73
+ """Get the provider type."""
74
+ return VMProviderType.LUME
75
+
76
+ async def __aenter__(self):
77
+ """Enter async context manager."""
78
+ # No initialization needed, just return self
79
+ return self
80
+
81
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
82
+ """Exit async context manager."""
83
+ # No cleanup needed
84
+ pass
85
+
86
+ def _lume_api_get(self, vm_name: str = "", storage: Optional[str] = None, debug: bool = False) -> Dict[str, Any]:
87
+ """Get VM information using shared lume_api function.
88
+
89
+ Args:
90
+ vm_name: Optional name of the VM to get info for.
91
+ If empty, lists all VMs.
92
+ storage: Optional storage path override. If provided, this will be used instead of self.storage
93
+ debug: Whether to show debug output
94
+
95
+ Returns:
96
+ Dictionary with VM status information parsed from JSON response
97
+ """
98
+ # Use the shared implementation from lume_api module
99
+ return lume_api_get(
100
+ vm_name=vm_name,
101
+ host=self.host,
102
+ port=self.port,
103
+ storage=storage if storage is not None else self.storage,
104
+ debug=debug,
105
+ verbose=self.verbose
106
+ )
107
+
108
+ def _lume_api_run(self, vm_name: str, run_opts: Dict[str, Any], debug: bool = False) -> Dict[str, Any]:
109
+ """Run a VM using shared lume_api function.
110
+
111
+ Args:
112
+ vm_name: Name of the VM to run
113
+ run_opts: Dictionary of run options
114
+ debug: Whether to show debug output
115
+
116
+ Returns:
117
+ Dictionary with API response or error information
118
+ """
119
+ # Use the shared implementation from lume_api module
120
+ return lume_api_run(
121
+ vm_name=vm_name,
122
+ host=self.host,
123
+ port=self.port,
124
+ run_opts=run_opts,
125
+ storage=self.storage,
126
+ debug=debug,
127
+ verbose=self.verbose
128
+ )
129
+
130
+ def _lume_api_stop(self, vm_name: str, debug: bool = False) -> Dict[str, Any]:
131
+ """Stop a VM using shared lume_api function.
132
+
133
+ Args:
134
+ vm_name: Name of the VM to stop
135
+ debug: Whether to show debug output
136
+
137
+ Returns:
138
+ Dictionary with API response or error information
139
+ """
140
+ # Use the shared implementation from lume_api module
141
+ return lume_api_stop(
142
+ vm_name=vm_name,
143
+ host=self.host,
144
+ port=self.port,
145
+ storage=self.storage,
146
+ debug=debug,
147
+ verbose=self.verbose
148
+ )
149
+
150
+ def _lume_api_update(self, vm_name: str, update_opts: Dict[str, Any], debug: bool = False) -> Dict[str, Any]:
151
+ """Update VM configuration using shared lume_api function.
152
+
153
+ Args:
154
+ vm_name: Name of the VM to update
155
+ update_opts: Dictionary of update options
156
+ debug: Whether to show debug output
157
+
158
+ Returns:
159
+ Dictionary with API response or error information
160
+ """
161
+ # Use the shared implementation from lume_api module
162
+ return lume_api_update(
163
+ vm_name=vm_name,
164
+ host=self.host,
165
+ port=self.port,
166
+ update_opts=update_opts,
167
+ storage=self.storage,
168
+ debug=debug,
169
+ verbose=self.verbose
170
+ )
171
+
172
+ async def get_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]:
173
+ """Get VM information by name.
174
+
175
+ Args:
176
+ name: Name of the VM to get information for
177
+ storage: Optional storage path override. If provided, this will be used
178
+ instead of the provider's default storage path.
179
+
180
+ Returns:
181
+ Dictionary with VM information including status, IP address, etc.
182
+
183
+ Note:
184
+ If storage is not provided, the provider's default storage path will be used.
185
+ The storage parameter allows overriding the storage location for this specific call.
186
+ """
187
+ if not HAS_CURL:
188
+ logger.error("curl is not available. Cannot get VM status.")
189
+ return {
190
+ "name": name,
191
+ "status": "unavailable",
192
+ "error": "curl is not available"
193
+ }
194
+
195
+ # First try to get detailed VM info from the API
196
+ try:
197
+ # Query the Lume API for VM status using the provider's storage_path
198
+ vm_info = self._lume_api_get(
199
+ vm_name=name,
200
+ storage=storage if storage is not None else self.storage,
201
+ debug=self.verbose
202
+ )
203
+
204
+ # Check for API errors
205
+ if "error" in vm_info:
206
+ logger.debug(f"API request error: {vm_info['error']}")
207
+ # If we got an error from the API, report the VM as not ready yet
208
+ return {
209
+ "name": name,
210
+ "status": "starting", # VM is still starting - do not attempt to connect yet
211
+ "api_status": "error",
212
+ "error": vm_info["error"]
213
+ }
214
+
215
+ # Process the VM status information
216
+ vm_status = vm_info.get("status", "unknown")
217
+
218
+ # Check if VM is stopped or not running - don't wait for IP in this case
219
+ if vm_status == "stopped":
220
+ logger.info(f"VM {name} is in '{vm_status}' state - not waiting for IP address")
221
+ # Return the status as-is without waiting for an IP
222
+ result = {
223
+ "name": name,
224
+ "status": vm_status,
225
+ **vm_info # Include all original fields from the API response
226
+ }
227
+ return result
228
+
229
+ # Handle field name differences between APIs
230
+ # Some APIs use camelCase, others use snake_case
231
+ if "vncUrl" in vm_info:
232
+ vnc_url = vm_info["vncUrl"]
233
+ elif "vnc_url" in vm_info:
234
+ vnc_url = vm_info["vnc_url"]
235
+ else:
236
+ vnc_url = ""
237
+
238
+ if "ipAddress" in vm_info:
239
+ ip_address = vm_info["ipAddress"]
240
+ elif "ip_address" in vm_info:
241
+ ip_address = vm_info["ip_address"]
242
+ else:
243
+ # If no IP address is provided and VM is supposed to be running,
244
+ # report it as still starting
245
+ ip_address = None
246
+ logger.info(f"VM {name} is in '{vm_status}' state but no IP address found - reporting as still starting")
247
+
248
+ logger.info(f"VM {name} status: {vm_status}")
249
+
250
+ # Return the complete status information
251
+ result = {
252
+ "name": name,
253
+ "status": vm_status if vm_status else "running",
254
+ "ip_address": ip_address,
255
+ "vnc_url": vnc_url,
256
+ "api_status": "ok"
257
+ }
258
+
259
+ # Include all original fields from the API response
260
+ if isinstance(vm_info, dict):
261
+ for key, value in vm_info.items():
262
+ if key not in result: # Don't override our carefully processed fields
263
+ result[key] = value
264
+
265
+ return result
266
+
267
+ except Exception as e:
268
+ logger.error(f"Failed to get VM status: {e}")
269
+ # Return a fallback status that indicates the VM is not ready yet
270
+ return {
271
+ "name": name,
272
+ "status": "initializing", # VM is still initializing
273
+ "error": f"Failed to get VM status: {str(e)}"
274
+ }
275
+
276
+ async def list_vms(self) -> List[Dict[str, Any]]:
277
+ """List all available VMs."""
278
+ result = self._lume_api_get(debug=self.verbose)
279
+
280
+ # Extract the VMs list from the response
281
+ if "vms" in result and isinstance(result["vms"], list):
282
+ return result["vms"]
283
+ elif "error" in result:
284
+ logger.error(f"Error listing VMs: {result['error']}")
285
+ return []
286
+ else:
287
+ return []
288
+
289
+ async def run_vm(self, image: str, name: str, run_opts: Dict[str, Any], storage: Optional[str] = None) -> Dict[str, Any]:
290
+ """Run a VM with the given options.
291
+
292
+ If the VM does not exist in the storage location, this will attempt to pull it
293
+ from the Lume registry first.
294
+
295
+ Args:
296
+ image: Image name to use when pulling the VM if it doesn't exist
297
+ name: Name of the VM to run
298
+ run_opts: Dictionary of run options (memory, cpu, etc.)
299
+ storage: Optional storage path override. If provided, this will be used
300
+ instead of the provider's default storage path.
301
+
302
+ Returns:
303
+ Dictionary with VM run status and information
304
+ """
305
+ # First check if VM exists by trying to get its info
306
+ vm_info = await self.get_vm(name, storage=storage)
307
+
308
+ if "error" in vm_info:
309
+ # VM doesn't exist, try to pull it
310
+ self.logger.info(f"VM {name} not found, attempting to pull image {image} from registry...")
311
+
312
+ # Call pull_vm with the image parameter
313
+ pull_result = await self.pull_vm(
314
+ name=name,
315
+ image=image,
316
+ storage=storage
317
+ )
318
+
319
+ # Check if pull was successful
320
+ if "error" in pull_result:
321
+ self.logger.error(f"Failed to pull VM image: {pull_result['error']}")
322
+ return pull_result # Return the error from pull
323
+
324
+ self.logger.info(f"Successfully pulled VM image {image} as {name}")
325
+
326
+ # Now run the VM with the given options
327
+ self.logger.info(f"Running VM {name} with options: {run_opts}")
328
+
329
+ from ..lume_api import lume_api_run
330
+ return lume_api_run(
331
+ vm_name=name,
332
+ host=self.host,
333
+ port=self.port,
334
+ run_opts=run_opts,
335
+ storage=storage if storage is not None else self.storage,
336
+ debug=self.verbose,
337
+ verbose=self.verbose
338
+ )
339
+
340
+ async def stop_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]:
341
+ """Stop a running VM.
342
+
343
+ If this provider was initialized with ephemeral=True, the VM will also
344
+ be deleted after it is stopped.
345
+
346
+ Args:
347
+ name: Name of the VM to stop
348
+ storage: Optional storage path override
349
+
350
+ Returns:
351
+ Dictionary with stop status and information
352
+ """
353
+ # Stop the VM first
354
+ stop_result = self._lume_api_stop(name, debug=self.verbose)
355
+
356
+ # Log ephemeral status for debugging
357
+ self.logger.info(f"Ephemeral mode status: {self.ephemeral}")
358
+
359
+ # If ephemeral mode is enabled, delete the VM after stopping
360
+ if self.ephemeral and (stop_result.get("success", False) or "error" not in stop_result):
361
+ self.logger.info(f"Ephemeral mode enabled - deleting VM {name} after stopping")
362
+ try:
363
+ delete_result = await self.delete_vm(name, storage=storage)
364
+
365
+ # Return combined result
366
+ return {
367
+ **stop_result, # Include all stop result info
368
+ "deleted": True,
369
+ "delete_result": delete_result
370
+ }
371
+ except Exception as e:
372
+ self.logger.error(f"Failed to delete ephemeral VM {name}: {e}")
373
+ # Include the error but still return stop result
374
+ return {
375
+ **stop_result,
376
+ "deleted": False,
377
+ "delete_error": str(e)
378
+ }
379
+
380
+ # Just return the stop result if not ephemeral
381
+ return stop_result
382
+
383
+ async def pull_vm(
384
+ self,
385
+ name: str,
386
+ image: str,
387
+ storage: Optional[str] = None,
388
+ registry: str = "ghcr.io",
389
+ organization: str = "trycua",
390
+ pull_opts: Optional[Dict[str, Any]] = None,
391
+ ) -> Dict[str, Any]:
392
+ """Pull a VM image from the registry.
393
+
394
+ Args:
395
+ name: Name for the VM after pulling
396
+ image: The image name to pull (e.g. 'macos-sequoia-cua:latest')
397
+ storage: Optional storage path to use
398
+ registry: Registry to pull from (default: ghcr.io)
399
+ organization: Organization in registry (default: trycua)
400
+ pull_opts: Additional options for pulling the VM (optional)
401
+
402
+ Returns:
403
+ Dictionary with information about the pulled VM
404
+
405
+ Raises:
406
+ RuntimeError: If pull operation fails or image is not provided
407
+ """
408
+ # Validate image parameter
409
+ if not image:
410
+ raise ValueError("Image parameter is required for pull_vm")
411
+
412
+ self.logger.info(f"Pulling VM image '{image}' as '{name}'")
413
+ self.logger.info("You can check the pull progress using: lume logs -f")
414
+
415
+ # Set default pull_opts if not provided
416
+ if pull_opts is None:
417
+ pull_opts = {}
418
+
419
+ # Log information about the operation
420
+ self.logger.debug(f"Pull storage location: {storage or 'default'}")
421
+
422
+ try:
423
+ # Call the lume_api_pull function from lume_api.py
424
+ from ..lume_api import lume_api_pull
425
+
426
+ result = lume_api_pull(
427
+ image=image,
428
+ name=name,
429
+ host=self.host,
430
+ port=self.port,
431
+ storage=storage if storage is not None else self.storage,
432
+ registry=registry,
433
+ organization=organization,
434
+ debug=self.verbose,
435
+ verbose=self.verbose
436
+ )
437
+
438
+ # Check for errors in the result
439
+ if "error" in result:
440
+ self.logger.error(f"Failed to pull VM image: {result['error']}")
441
+ return result
442
+
443
+ self.logger.info(f"Successfully pulled VM image '{image}' as '{name}'")
444
+ return result
445
+ except Exception as e:
446
+ self.logger.error(f"Failed to pull VM image '{image}': {e}")
447
+ return {"error": f"Failed to pull VM: {str(e)}"}
448
+
449
+ async def delete_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]:
450
+ """Delete a VM permanently.
451
+
452
+ Args:
453
+ name: Name of the VM to delete
454
+ storage: Optional storage path override
455
+
456
+ Returns:
457
+ Dictionary with delete status and information
458
+ """
459
+ self.logger.info(f"Deleting VM {name}...")
460
+
461
+ try:
462
+ # Call the lume_api_delete function we created
463
+ from ..lume_api import lume_api_delete
464
+
465
+ result = lume_api_delete(
466
+ vm_name=name,
467
+ host=self.host,
468
+ port=self.port,
469
+ storage=storage if storage is not None else self.storage,
470
+ debug=self.verbose,
471
+ verbose=self.verbose
472
+ )
473
+
474
+ # Check for errors in the result
475
+ if "error" in result:
476
+ self.logger.error(f"Failed to delete VM: {result['error']}")
477
+ return result
478
+
479
+ self.logger.info(f"Successfully deleted VM '{name}'")
480
+ return result
481
+ except Exception as e:
482
+ self.logger.error(f"Failed to delete VM '{name}': {e}")
483
+ return {"error": f"Failed to delete VM: {str(e)}"}
484
+
485
+ async def update_vm(self, name: str, update_opts: Dict[str, Any], storage: Optional[str] = None) -> Dict[str, Any]:
486
+ """Update VM configuration."""
487
+ return self._lume_api_update(name, update_opts, debug=self.verbose)
488
+
489
+ async def get_ip(self, name: str, storage: Optional[str] = None, retry_delay: int = 2) -> str:
490
+ """Get the IP address of a VM, waiting indefinitely until it's available.
491
+
492
+ Args:
493
+ name: Name of the VM to get the IP for
494
+ storage: Optional storage path override
495
+ retry_delay: Delay between retries in seconds (default: 2)
496
+
497
+ Returns:
498
+ IP address of the VM when it becomes available
499
+ """
500
+ # Track total attempts for logging purposes
501
+ total_attempts = 0
502
+
503
+ # Loop indefinitely until we get a valid IP
504
+ while True:
505
+ total_attempts += 1
506
+
507
+ # Log retry message but not on first attempt
508
+ if total_attempts > 1:
509
+ self.logger.info(f"Waiting for VM {name} IP address (attempt {total_attempts})...")
510
+
511
+ try:
512
+ # Get VM information
513
+ vm_info = await self.get_vm(name, storage=storage)
514
+
515
+ # Check if we got a valid IP
516
+ ip = vm_info.get("ip_address", None)
517
+ if ip and ip != "unknown" and not ip.startswith("0.0.0.0"):
518
+ self.logger.info(f"Got valid VM IP address: {ip}")
519
+ return ip
520
+
521
+ # Check the VM status
522
+ status = vm_info.get("status", "unknown")
523
+
524
+ # If VM is not running yet, log and wait
525
+ if status != "running":
526
+ self.logger.info(f"VM is not running yet (status: {status}). Waiting...")
527
+ # If VM is running but no IP yet, wait and retry
528
+ else:
529
+ self.logger.info("VM is running but no valid IP address yet. Waiting...")
530
+
531
+ except Exception as e:
532
+ self.logger.warning(f"Error getting VM {name} IP: {e}, continuing to wait...")
533
+
534
+ # Wait before next retry
535
+ await asyncio.sleep(retry_delay)
536
+
537
+ # Add progress log every 10 attempts
538
+ if total_attempts % 10 == 0:
539
+ self.logger.info(f"Still waiting for VM {name} IP after {total_attempts} attempts...")
540
+
541
+