request-vm-on-golem 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,642 @@
1
+ import click
2
+ import asyncio
3
+ from typing import Optional
4
+ from pathlib import Path
5
+ import subprocess
6
+ import aiohttp
7
+ from tabulate import tabulate
8
+
9
+ from ..config import config
10
+ from ..provider.client import ProviderClient
11
+ from ..ssh.manager import SSHKeyManager
12
+ from ..db.sqlite import Database
13
+ from ..errors import RequestorError
14
+ from ..utils.logging import setup_logger
15
+ from ..utils.spinner import step
16
+
17
+ # Initialize logger
18
+ logger = setup_logger('golem.requestor')
19
+
20
+ # Initialize components
21
+ db = Database(config.db_path)
22
+
23
+
24
+ def async_command(f):
25
+ """Decorator to run async commands."""
26
+ async def wrapper(*args, **kwargs):
27
+ # Initialize database
28
+ await db.init()
29
+ return await f(*args, **kwargs)
30
+ return lambda *args, **kwargs: asyncio.run(wrapper(*args, **kwargs))
31
+
32
+
33
+ @click.group()
34
+ def cli():
35
+ """VM on Golem management CLI"""
36
+ pass
37
+
38
+
39
+ @cli.group()
40
+ def vm():
41
+ """VM management commands"""
42
+ pass
43
+
44
+
45
+ @vm.command(name='providers')
46
+ @click.option('--cpu', type=int, help='Minimum CPU cores required')
47
+ @click.option('--memory', type=int, help='Minimum memory (GB) required')
48
+ @click.option('--storage', type=int, help='Minimum storage (GB) required')
49
+ @click.option('--country', help='Preferred provider country')
50
+ @async_command
51
+ async def list_providers(cpu: Optional[int], memory: Optional[int], storage: Optional[int], country: Optional[str]):
52
+ """List available providers matching requirements."""
53
+ try:
54
+ # Log search criteria if any
55
+ if any([cpu, memory, storage, country]):
56
+ logger.command("🔍 Searching for providers with criteria:")
57
+ if cpu:
58
+ logger.detail(f"CPU Cores: {cpu}+")
59
+ if memory:
60
+ logger.detail(f"Memory: {memory}GB+")
61
+ if storage:
62
+ logger.detail(f"Storage: {storage}GB+")
63
+ if country:
64
+ logger.detail(f"Country: {country}")
65
+
66
+ logger.process("Querying discovery service")
67
+
68
+ params = {
69
+ k: v for k, v in {
70
+ 'cpu': cpu,
71
+ 'memory': memory,
72
+ 'storage': storage,
73
+ 'country': country
74
+ }.items() if v is not None
75
+ }
76
+
77
+ async with aiohttp.ClientSession() as session:
78
+ async with session.get(
79
+ f"{config.discovery_url}/api/v1/advertisements",
80
+ params=params
81
+ ) as response:
82
+ if not response.ok:
83
+ raise RequestorError("Failed to query discovery service")
84
+ providers = await response.json()
85
+
86
+ if not providers:
87
+ logger.warning("No providers found matching criteria")
88
+ return
89
+
90
+ # Format provider information
91
+ headers = ["Provider ID", "IP Address", "Country",
92
+ "CPU", "Memory (GB)", "Storage (GB)", "Updated"]
93
+ rows = []
94
+ for p in providers:
95
+ # Get provider IP based on environment
96
+ provider_ip = 'localhost' if config.environment == "development" else p.get(
97
+ 'ip_address')
98
+ if not provider_ip and config.environment == "production":
99
+ logger.warning(f"Provider {p['provider_id']} has no IP address")
100
+ provider_ip = 'N/A'
101
+ rows.append([
102
+ p['provider_id'],
103
+ provider_ip,
104
+ p['country'],
105
+ p['resources']['cpu'],
106
+ p['resources']['memory'],
107
+ p['resources']['storage'],
108
+ p['updated_at']
109
+ ])
110
+
111
+ # Show fancy header
112
+ click.echo("\n" + "─" * 60)
113
+ click.echo(click.style(f" 🌍 Available Providers ({len(providers)} total)", fg="blue", bold=True))
114
+ click.echo("─" * 60)
115
+
116
+ # Add color and formatting to columns
117
+ for row in rows:
118
+ # Format Provider ID to be more readable
119
+ row[0] = click.style(row[0], fg="yellow") # Provider ID
120
+
121
+ # Format resources with icons and colors
122
+ row[3] = click.style(f"💻 {row[3]}", fg="cyan", bold=True) # CPU
123
+ row[4] = click.style(f"🧠 {row[4]}", fg="cyan", bold=True) # Memory
124
+ row[5] = click.style(f"💾 {row[5]}", fg="cyan", bold=True) # Storage
125
+
126
+ # Format location info
127
+ row[2] = click.style(f"🌍 {row[2]}", fg="green", bold=True) # Country
128
+
129
+ # Show table with colored headers
130
+ click.echo("\n" + tabulate(
131
+ rows,
132
+ headers=[click.style(h, bold=True) for h in headers],
133
+ tablefmt="grid"
134
+ ))
135
+ click.echo("\n" + "─" * 60)
136
+
137
+ except Exception as e:
138
+ logger.error(f"Failed to list providers: {str(e)}")
139
+ raise click.Abort()
140
+
141
+
142
+ # Helper functions for VM creation with spinners
143
+ @step("Checking VM name availability")
144
+ async def check_vm_name(db, name):
145
+ existing_vm = await db.get_vm(name)
146
+ if existing_vm:
147
+ raise RequestorError(f"VM with name '{name}' already exists")
148
+
149
+ @step("Locating provider")
150
+ async def find_provider(discovery_url, provider_id):
151
+ async with aiohttp.ClientSession() as session:
152
+ async with session.get(f"{discovery_url}/api/v1/advertisements") as response:
153
+ if not response.ok:
154
+ raise RequestorError("Failed to query discovery service")
155
+ providers = await response.json()
156
+
157
+ provider = next((p for p in providers if p['provider_id'] == provider_id), None)
158
+ if not provider:
159
+ raise RequestorError(f"Provider {provider_id} not found")
160
+ return provider
161
+
162
+ @step("Verifying resource availability")
163
+ async def verify_resources(provider, cpu, memory, storage):
164
+ if (cpu > provider['resources']['cpu'] or
165
+ memory > provider['resources']['memory'] or
166
+ storage > provider['resources']['storage']):
167
+ raise RequestorError("Requested resources exceed provider capacity")
168
+
169
+ @step("Setting up SSH access")
170
+ async def setup_ssh(ssh_manager):
171
+ return await ssh_manager.get_key_pair()
172
+
173
+ @step("Deploying VM on provider")
174
+ async def deploy_vm(client, name, cpu, memory, storage, ssh_key):
175
+ return await client.create_vm(
176
+ name=name,
177
+ cpu=cpu,
178
+ memory=memory,
179
+ storage=storage,
180
+ ssh_key=ssh_key
181
+ )
182
+
183
+ @step("Configuring VM access")
184
+ async def get_vm_access(client, vm_id):
185
+ return await client.get_vm_access(vm_id)
186
+
187
+ @step("Saving VM configuration")
188
+ async def save_vm_config(db, name, provider_ip, vm_id, config):
189
+ await db.save_vm(
190
+ name=name,
191
+ provider_ip=provider_ip,
192
+ vm_id=vm_id,
193
+ config=config
194
+ )
195
+
196
+ @vm.command(name='create')
197
+ @click.argument('name')
198
+ @click.option('--provider-id', required=True, help='Provider ID to use')
199
+ @click.option('--cpu', type=int, required=True, help='Number of CPU cores')
200
+ @click.option('--memory', type=int, required=True, help='Memory in GB')
201
+ @click.option('--storage', type=int, required=True, help='Storage in GB')
202
+ @async_command
203
+ async def create_vm(name: str, provider_id: str, cpu: int, memory: int, storage: int):
204
+ """Create a new VM on a specific provider."""
205
+ try:
206
+ logger.command(f"🚀 Creating VM '{name}'")
207
+ logger.detail(f"Provider: {provider_id}")
208
+ logger.detail(f"Resources: {cpu} CPU, {memory}GB RAM, {storage}GB Storage")
209
+
210
+ # Check VM name
211
+ await check_vm_name(db, name)
212
+
213
+ # Find and verify provider
214
+ provider = await find_provider(config.discovery_url, provider_id)
215
+ await verify_resources(provider, cpu, memory, storage)
216
+
217
+ # Setup SSH
218
+ ssh_manager = SSHKeyManager(config.ssh_key_dir)
219
+ key_pair = await setup_ssh(ssh_manager)
220
+
221
+ # Get provider IP
222
+ provider_ip = 'localhost' if config.environment == "development" else provider.get('ip_address')
223
+ if not provider_ip and config.environment == "production":
224
+ raise RequestorError("Provider IP address not found in advertisement")
225
+
226
+ # Create and configure VM
227
+ provider_url = config.get_provider_url(provider_ip)
228
+ async with ProviderClient(provider_url) as client:
229
+ # Create VM and get full VM ID
230
+ vm = await deploy_vm(client, name, cpu, memory, storage, key_pair.public_key_content)
231
+ access_info = await get_vm_access(client, vm['id'])
232
+
233
+ # Store the full VM ID from access info
234
+ full_vm_id = access_info['vm_id']
235
+
236
+ await save_vm_config(
237
+ db, name, provider_ip, full_vm_id,
238
+ {
239
+ 'cpu': cpu,
240
+ 'memory': memory,
241
+ 'storage': storage,
242
+ 'ssh_port': access_info['ssh_port']
243
+ }
244
+ )
245
+
246
+ # Create a visually appealing success message
247
+ click.echo("\n" + "─" * 60)
248
+ click.echo(click.style(" 🎉 VM Deployed Successfully!", fg="green", bold=True))
249
+ click.echo("─" * 60 + "\n")
250
+
251
+ # VM Details Section
252
+ click.echo(click.style(" VM Details", fg="blue", bold=True))
253
+ click.echo(" " + "┈" * 25)
254
+ click.echo(f" 🏷️ Name : {click.style(name, fg='cyan')}")
255
+ click.echo(f" 💻 Resources : {click.style(f'{cpu} CPU, {memory}GB RAM, {storage}GB Storage', fg='cyan')}")
256
+ click.echo(f" 🟢 Status : {click.style('running', fg='green')}")
257
+
258
+ # Connection Details Section
259
+ click.echo("\n" + click.style(" Connection Details", fg="blue", bold=True))
260
+ click.echo(" " + "┈" * 25)
261
+ click.echo(f" 🌐 IP Address : {click.style(provider_ip, fg='cyan')}")
262
+ click.echo(f" 🔌 Port : {click.style(str(access_info['ssh_port']), fg='cyan')}")
263
+
264
+ # Quick Connect Section
265
+ click.echo("\n" + click.style(" Quick Connect", fg="blue", bold=True))
266
+ click.echo(" " + "┈" * 25)
267
+ ssh_command = f"ssh -i {key_pair.private_key.absolute()} -p {access_info['ssh_port']} ubuntu@{provider_ip}"
268
+ click.echo(f" 🔑 SSH Command : {click.style(ssh_command, fg='yellow')}")
269
+
270
+ click.echo("\n" + "─" * 60)
271
+
272
+ except Exception as e:
273
+ error_msg = str(e)
274
+ if "Failed to query discovery service" in error_msg:
275
+ error_msg = "Unable to reach discovery service (check your internet connection)"
276
+ elif "Provider" in error_msg and "not found" in error_msg:
277
+ error_msg = "Provider is no longer available (they may have gone offline)"
278
+ elif "capacity" in error_msg:
279
+ error_msg = "Provider doesn't have enough resources available"
280
+ logger.error(f"Failed to create VM: {error_msg}")
281
+ raise click.Abort()
282
+
283
+
284
+ @vm.command(name='ssh')
285
+ @click.argument('name')
286
+ @async_command
287
+ async def ssh_vm(name: str):
288
+ """SSH into a VM."""
289
+ try:
290
+ logger.command(f"🔌 Connecting to VM '{name}'")
291
+
292
+ # Get VM details
293
+ logger.process("Retrieving VM details")
294
+ vm = await db.get_vm(name)
295
+ if not vm:
296
+ raise click.BadParameter(f"VM '{name}' not found")
297
+
298
+ # Get SSH key
299
+ logger.process("Loading SSH credentials")
300
+ ssh_manager = SSHKeyManager(config.ssh_key_dir)
301
+ key_pair = await ssh_manager.get_key_pair()
302
+
303
+ # Get VM access info
304
+ logger.process("Fetching connection details")
305
+ provider_url = config.get_provider_url(vm['provider_ip'])
306
+ async with ProviderClient(provider_url) as client:
307
+ access_info = await client.get_vm_access(vm['vm_id'])
308
+
309
+ # Execute SSH command
310
+ logger.success(f"Connecting to {vm['provider_ip']}:{access_info['ssh_port']}")
311
+ cmd = [
312
+ "ssh",
313
+ "-i", str(key_pair.private_key.absolute()),
314
+ "-p", str(access_info['ssh_port']),
315
+ "-o", "StrictHostKeyChecking=no",
316
+ "-o", "UserKnownHostsFile=/dev/null",
317
+ f"ubuntu@{vm['provider_ip']}"
318
+ ]
319
+ subprocess.run(cmd)
320
+
321
+ except Exception as e:
322
+ error_msg = str(e)
323
+ if "VM 'test-vm' not found" in error_msg:
324
+ error_msg = "VM not found in local database"
325
+ elif "Not Found" in error_msg:
326
+ error_msg = "VM not found on provider (it may have been manually removed)"
327
+ elif "Connection refused" in error_msg:
328
+ error_msg = "Unable to establish SSH connection (VM may be starting up)"
329
+ logger.error(f"Failed to connect: {error_msg}")
330
+ raise click.Abort()
331
+
332
+
333
+ @vm.command(name='destroy')
334
+ @click.argument('name')
335
+ @async_command
336
+ async def destroy_vm(name: str):
337
+ """Destroy a VM."""
338
+ try:
339
+ logger.command(f"💥 Destroying VM '{name}'")
340
+
341
+ # Get VM details
342
+ logger.process("Retrieving VM details")
343
+ vm = await db.get_vm(name)
344
+ if not vm:
345
+ raise click.BadParameter(f"VM '{name}' not found")
346
+
347
+ try:
348
+ # Connect to provider using full VM ID
349
+ logger.process("Requesting VM termination")
350
+ provider_url = config.get_provider_url(vm['provider_ip'])
351
+ async with ProviderClient(provider_url) as client:
352
+ await client.destroy_vm(vm['vm_id'])
353
+ logger.success("VM terminated on provider")
354
+ except Exception as e:
355
+ error_msg = str(e)
356
+ if "Not Found" in error_msg:
357
+ logger.warning("VM already removed from provider")
358
+ else:
359
+ raise
360
+
361
+ # Always remove from database
362
+ logger.process("Cleaning up VM records")
363
+ await db.delete_vm(name)
364
+
365
+ # Show fancy success message
366
+ click.echo("\n" + "─" * 60)
367
+ click.echo(click.style(" 💥 VM Destroyed Successfully!", fg="red", bold=True))
368
+ click.echo("─" * 60 + "\n")
369
+
370
+ click.echo(click.style(" Summary", fg="blue", bold=True))
371
+ click.echo(" " + "┈" * 25)
372
+ click.echo(f" 🏷️ Name : {click.style(name, fg='cyan')}")
373
+ click.echo(f" 🗑️ Status : {click.style('destroyed', fg='red')}")
374
+ click.echo(f" ⏱️ Time : {click.style('just now', fg='cyan')}")
375
+
376
+ click.echo("\n" + "─" * 60)
377
+
378
+ except Exception as e:
379
+ error_msg = str(e)
380
+ if "VM 'test-vm' not found" in error_msg:
381
+ error_msg = "VM not found in local database"
382
+ elif "Not Found" in error_msg:
383
+ error_msg = "VM not found on provider (it may have been manually removed)"
384
+ logger.error(f"Failed to destroy VM: {error_msg}")
385
+ raise click.Abort()
386
+
387
+
388
+ @vm.command(name='purge')
389
+ @click.option('--force', is_flag=True, help='Force purge even if errors occur')
390
+ @click.confirmation_option(prompt='Are you sure you want to purge all VMs?')
391
+ @async_command
392
+ async def purge_vms(force: bool):
393
+ """Purge all VMs and clean up local database."""
394
+ try:
395
+ logger.command("🌪️ Purging all VMs")
396
+
397
+ # Get all VMs
398
+ logger.process("Retrieving all VM details")
399
+ vms = await db.list_vms()
400
+ if not vms:
401
+ logger.warning("No VMs found to purge")
402
+ return
403
+
404
+ # Track results
405
+ results = {
406
+ 'success': [],
407
+ 'failed': []
408
+ }
409
+
410
+ # Process each VM
411
+ for vm in vms:
412
+ try:
413
+ logger.process(f"Purging VM '{vm['name']}'")
414
+
415
+ try:
416
+ # Try to destroy on provider using full VM ID
417
+ provider_url = config.get_provider_url(vm['provider_ip'])
418
+ async with ProviderClient(provider_url) as client:
419
+ # Get latest VM access info to ensure we have the correct ID
420
+ try:
421
+ access_info = await client.get_vm_access(vm['vm_id'])
422
+ full_vm_id = access_info['vm_id']
423
+ except Exception:
424
+ # If we can't get access info, use stored VM ID
425
+ full_vm_id = vm['vm_id']
426
+
427
+ await client.destroy_vm(full_vm_id)
428
+ results['success'].append((vm['name'], 'Destroyed successfully'))
429
+ except Exception as e:
430
+ error_msg = str(e)
431
+ if "Not Found" in error_msg:
432
+ results['success'].append((vm['name'], 'Already removed from provider'))
433
+ else:
434
+ if not force:
435
+ raise
436
+ results['failed'].append((vm['name'], f"Provider error: {error_msg}"))
437
+
438
+ # Always remove from local database
439
+ await db.delete_vm(vm['name'])
440
+
441
+ except Exception as e:
442
+ if not force:
443
+ raise
444
+ results['failed'].append((vm['name'], str(e)))
445
+
446
+ # Show results
447
+ click.echo("\n" + "─" * 60)
448
+ click.echo(click.style(" 🌪️ VM Purge Complete", fg="blue", bold=True))
449
+ click.echo("─" * 60 + "\n")
450
+
451
+ # Success section
452
+ if results['success']:
453
+ click.echo(click.style(" ✅ Successfully Purged", fg="green", bold=True))
454
+ click.echo(" " + "┈" * 25)
455
+ for name, msg in results['success']:
456
+ click.echo(f" • {click.style(name, fg='cyan')}: {click.style(msg, fg='green')}")
457
+ click.echo()
458
+
459
+ # Failures section
460
+ if results['failed']:
461
+ click.echo(click.style(" ❌ Failed to Purge", fg="red", bold=True))
462
+ click.echo(" " + "┈" * 25)
463
+ for name, error in results['failed']:
464
+ click.echo(f" • {click.style(name, fg='cyan')}: {click.style(error, fg='red')}")
465
+ click.echo()
466
+
467
+ # Summary
468
+ total = len(results['success']) + len(results['failed'])
469
+ success_rate = (len(results['success']) / total) * 100 if total > 0 else 0
470
+
471
+ click.echo(click.style(" 📊 Summary", fg="blue", bold=True))
472
+ click.echo(" " + "┈" * 25)
473
+ click.echo(f" 📈 Success Rate : {click.style(f'{success_rate:.1f}%', fg='cyan')}")
474
+ click.echo(f" ✅ Successful : {click.style(str(len(results['success'])), fg='green')}")
475
+ click.echo(f" ❌ Failed : {click.style(str(len(results['failed'])), fg='red')}")
476
+ click.echo(f" 📋 Total VMs : {click.style(str(total), fg='cyan')}")
477
+
478
+ click.echo("\n" + "─" * 60)
479
+
480
+ except Exception as e:
481
+ error_msg = str(e)
482
+ if "database" in error_msg.lower():
483
+ error_msg = "Failed to access local database"
484
+ logger.error(f"Purge operation failed: {error_msg}")
485
+ raise click.Abort()
486
+
487
+
488
+ @vm.command(name='start')
489
+ @click.argument('name')
490
+ @async_command
491
+ async def start_vm(name: str):
492
+ """Start a VM."""
493
+ try:
494
+ logger.command(f"🟢 Starting VM '{name}'")
495
+
496
+ # Get VM details
497
+ logger.process("Retrieving VM details")
498
+ vm = await db.get_vm(name)
499
+ if not vm:
500
+ raise click.BadParameter(f"VM '{name}' not found")
501
+
502
+ # Connect to provider
503
+ logger.process("Powering up VM")
504
+ provider_url = config.get_provider_url(vm['provider_ip'])
505
+ async with ProviderClient(provider_url) as client:
506
+ await client.start_vm(vm['vm_id'])
507
+ await db.update_vm_status(name, "running")
508
+
509
+ # Show fancy success message
510
+ click.echo("\n" + "─" * 60)
511
+ click.echo(click.style(" 🟢 VM Started Successfully!", fg="green", bold=True))
512
+ click.echo("─" * 60 + "\n")
513
+
514
+ click.echo(click.style(" VM Status", fg="blue", bold=True))
515
+ click.echo(" " + "┈" * 25)
516
+ click.echo(f" 🏷️ Name : {click.style(name, fg='cyan')}")
517
+ click.echo(f" 💫 Status : {click.style('running', fg='green')}")
518
+ click.echo(f" 🌐 IP Address : {click.style(vm['provider_ip'], fg='cyan')}")
519
+ click.echo(f" 🔌 Port : {click.style(str(vm['config']['ssh_port']), fg='cyan')}")
520
+
521
+ click.echo("\n" + "─" * 60)
522
+
523
+ except Exception as e:
524
+ error_msg = str(e)
525
+ if "VM 'test-vm' not found" in error_msg:
526
+ error_msg = "VM not found in local database"
527
+ elif "Not Found" in error_msg:
528
+ error_msg = "VM not found on provider (it may have been manually removed)"
529
+ elif "already running" in error_msg.lower():
530
+ error_msg = "VM is already running"
531
+ logger.error(f"Failed to start VM: {error_msg}")
532
+ raise click.Abort()
533
+
534
+
535
+ @vm.command(name='stop')
536
+ @click.argument('name')
537
+ @async_command
538
+ async def stop_vm(name: str):
539
+ """Stop a VM."""
540
+ try:
541
+ logger.command(f"🔴 Stopping VM '{name}'")
542
+
543
+ # Get VM details
544
+ logger.process("Retrieving VM details")
545
+ vm = await db.get_vm(name)
546
+ if not vm:
547
+ raise click.BadParameter(f"VM '{name}' not found")
548
+
549
+ # Connect to provider
550
+ logger.process("Shutting down VM")
551
+ provider_url = config.get_provider_url(vm['provider_ip'])
552
+ async with ProviderClient(provider_url) as client:
553
+ await client.stop_vm(vm['vm_id'])
554
+ await db.update_vm_status(name, "stopped")
555
+
556
+ # Show fancy success message
557
+ click.echo("\n" + "─" * 60)
558
+ click.echo(click.style(" 🔴 VM Stopped Successfully!", fg="yellow", bold=True))
559
+ click.echo("─" * 60 + "\n")
560
+
561
+ click.echo(click.style(" VM Status", fg="blue", bold=True))
562
+ click.echo(" " + "┈" * 25)
563
+ click.echo(f" 🏷️ Name : {click.style(name, fg='cyan')}")
564
+ click.echo(f" 💫 Status : {click.style('stopped', fg='yellow')}")
565
+ click.echo(f" 💾 Resources : {click.style('preserved', fg='cyan')}")
566
+
567
+ click.echo("\n" + "─" * 60)
568
+
569
+ except Exception as e:
570
+ error_msg = str(e)
571
+ if "Not Found" in error_msg:
572
+ error_msg = "VM not found on provider (it may have been manually removed)"
573
+ logger.error(f"Failed to stop VM: {error_msg}")
574
+ raise click.Abort()
575
+
576
+
577
+ @vm.command(name='list')
578
+ @async_command
579
+ async def list_vms():
580
+ """List all VMs."""
581
+ try:
582
+ logger.command("📋 Listing your VMs")
583
+ logger.process("Fetching VM details")
584
+
585
+ vms = await db.list_vms()
586
+ if not vms:
587
+ logger.warning("No VMs found")
588
+ return
589
+
590
+ headers = ["Name", "Status", "IP Address", "SSH Port",
591
+ "CPU", "Memory (GB)", "Storage (GB)", "Created"]
592
+ rows = []
593
+ for vm in vms:
594
+ rows.append([
595
+ vm['name'],
596
+ vm['status'],
597
+ vm['provider_ip'],
598
+ vm['config'].get('ssh_port', 'N/A'),
599
+ vm['config']['cpu'],
600
+ vm['config']['memory'],
601
+ vm['config']['storage'],
602
+ vm['created_at']
603
+ ])
604
+
605
+ # Show fancy header
606
+ click.echo("\n" + "─" * 60)
607
+ click.echo(click.style(f" 📋 Your VMs ({len(vms)} total)", fg="blue", bold=True))
608
+ click.echo("─" * 60)
609
+
610
+ # Add color to status column (index 1)
611
+ for i, row in enumerate(rows):
612
+ status = row[1]
613
+ if status == "running":
614
+ rows[i][1] = click.style("● " + status, fg="green", bold=True)
615
+ elif status == "stopped":
616
+ rows[i][1] = click.style("● " + status, fg="yellow", bold=True)
617
+ else:
618
+ rows[i][1] = click.style("● " + status, fg="red", bold=True)
619
+
620
+ # Show table with colored status
621
+ click.echo("\n" + tabulate(
622
+ rows,
623
+ headers=[click.style(h, bold=True) for h in headers],
624
+ tablefmt="grid"
625
+ ))
626
+ click.echo("\n" + "─" * 60)
627
+
628
+ except Exception as e:
629
+ error_msg = str(e)
630
+ if "database" in error_msg.lower():
631
+ error_msg = "Failed to access local database (try running the command again)"
632
+ logger.error(f"Failed to list VMs: {error_msg}")
633
+ raise click.Abort()
634
+
635
+
636
+ def main():
637
+ """Entry point for the CLI."""
638
+ cli()
639
+
640
+
641
+ if __name__ == '__main__':
642
+ main()