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.
- request_vm_on_golem-0.1.0.dist-info/METADATA +296 -0
- request_vm_on_golem-0.1.0.dist-info/RECORD +18 -0
- request_vm_on_golem-0.1.0.dist-info/WHEEL +4 -0
- request_vm_on_golem-0.1.0.dist-info/entry_points.txt +3 -0
- requestor/__init__.py +7 -0
- requestor/cli/__init__.py +1 -0
- requestor/cli/commands.py +642 -0
- requestor/config.py +67 -0
- requestor/db/__init__.py +5 -0
- requestor/db/sqlite.py +120 -0
- requestor/errors.py +29 -0
- requestor/provider/__init__.py +5 -0
- requestor/provider/client.py +94 -0
- requestor/run.py +51 -0
- requestor/ssh/__init__.py +4 -0
- requestor/ssh/manager.py +161 -0
- requestor/utils/logging.py +109 -0
- requestor/utils/spinner.py +72 -0
@@ -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()
|