Skip to content

Commit 413babe

Browse files
authored
Merge pull request #310 from one-covenant/feat/hyperstack-debug
feat(scripts): add Hyperstack VM debug utility
2 parents 7dbac54 + 83d79c8 commit 413babe

File tree

1 file changed

+390
-0
lines changed

1 file changed

+390
-0
lines changed

scripts/hyperstack-debug.py

Lines changed: 390 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,390 @@
1+
#!/usr/bin/env python3
2+
# /// script
3+
# requires-python = ">=3.10"
4+
# dependencies = [
5+
# "httpx",
6+
# "rich",
7+
# ]
8+
# ///
9+
"""Interactive script to debug and manage Hyperstack VMs."""
10+
11+
import os
12+
import sys
13+
import json
14+
15+
import httpx
16+
from rich.console import Console
17+
from rich.table import Table
18+
from rich.prompt import Prompt, Confirm
19+
from rich.panel import Panel
20+
from rich.syntax import Syntax
21+
22+
BASE_URL = "https://infrahub-api.nexgencloud.com/v1"
23+
CALLBACK_URL_BASE = "https://api.basilica.ai/webhooks/cloud-provider/hyperstack"
24+
console = Console()
25+
DEBUG = False
26+
27+
28+
def get_api_key() -> str:
29+
"""Get API key from environment or prompt user."""
30+
api_key = os.environ.get("HYPERSTACK_API_KEY")
31+
if api_key:
32+
console.print("[dim]Using API key from HYPERSTACK_API_KEY environment variable[/dim]")
33+
return api_key
34+
35+
api_key = Prompt.ask("Enter your Hyperstack API key", password=True)
36+
if not api_key:
37+
console.print("[red]API key is required[/red]")
38+
sys.exit(1)
39+
return api_key
40+
41+
42+
def get_callback_token() -> str:
43+
"""Get callback token from environment or command-line argument or prompt."""
44+
# Check command-line argument (--callback-token=xxx)
45+
for arg in sys.argv[1:]:
46+
if arg.startswith("--callback-token="):
47+
return arg.split("=", 1)[1]
48+
49+
# Check environment variable
50+
token = os.environ.get("BASILICA_CALLBACK_TOKEN")
51+
if token:
52+
console.print("[dim]Using token from BASILICA_CALLBACK_TOKEN environment variable[/dim]")
53+
return token
54+
55+
# Prompt user
56+
token = Prompt.ask("Enter callback token", password=True)
57+
if not token:
58+
console.print("[red]Callback token is required[/red]")
59+
sys.exit(1)
60+
return token
61+
62+
63+
def build_callback_url(token: str) -> str:
64+
"""Build the full callback URL with token."""
65+
return f"{CALLBACK_URL_BASE}?token={token}"
66+
67+
68+
def list_vms(client: httpx.Client) -> list[dict]:
69+
"""Fetch all VMs from Hyperstack API."""
70+
response = client.get(f"{BASE_URL}/core/virtual-machines")
71+
response.raise_for_status()
72+
data = response.json()
73+
74+
if DEBUG:
75+
console.print(f"[dim]Raw API response keys: {list(data.keys())}[/dim]")
76+
console.print(f"[dim]{json.dumps(data, indent=2)[:2000]}[/dim]")
77+
78+
if not data.get("status"):
79+
console.print(f"[red]API error: {data.get('message', 'Unknown error')}[/red]")
80+
return []
81+
82+
# Try different possible keys for VM list
83+
vms = data.get("virtual_machines") or data.get("instances") or data.get("data") or []
84+
return vms
85+
86+
87+
def get_vm_by_id(client: httpx.Client, vm_id: int) -> dict | None:
88+
"""Fetch a single VM by ID."""
89+
try:
90+
response = client.get(f"{BASE_URL}/core/virtual-machines/{vm_id}")
91+
response.raise_for_status()
92+
data = response.json()
93+
94+
if DEBUG:
95+
console.print(f"[dim]Raw API response:[/dim]")
96+
console.print(Syntax(json.dumps(data, indent=2), "json", theme="monokai"))
97+
98+
if not data.get("status"):
99+
console.print(f"[red]API error: {data.get('message', 'Unknown error')}[/red]")
100+
return None
101+
102+
return data.get("virtual_machine") or data.get("instance") or data
103+
except httpx.HTTPStatusError as e:
104+
console.print(f"[red]HTTP error: {e.response.status_code}[/red]")
105+
if e.response.status_code == 404:
106+
console.print(f"[yellow]VM with ID {vm_id} not found[/yellow]")
107+
return None
108+
109+
110+
def delete_vm(client: httpx.Client, vm_id: int, vm_name: str) -> bool:
111+
"""Delete a VM by ID. Returns True if successful."""
112+
try:
113+
response = client.delete(f"{BASE_URL}/core/virtual-machines/{vm_id}")
114+
response.raise_for_status()
115+
data = response.json()
116+
117+
if data.get("status"):
118+
console.print(f"[green]Successfully deleted VM '{vm_name}' (ID: {vm_id})[/green]")
119+
return True
120+
else:
121+
console.print(f"[red]Failed to delete VM '{vm_name}': {data.get('message', 'Unknown error')}[/red]")
122+
return False
123+
except httpx.HTTPStatusError as e:
124+
console.print(f"[red]HTTP error deleting VM '{vm_name}': {e.response.status_code}[/red]")
125+
return False
126+
127+
128+
def attach_callback(client: httpx.Client, vm_id: int, vm_name: str, callback_url: str) -> bool:
129+
"""Attach a callback URL to a VM. Returns True if successful."""
130+
try:
131+
response = client.post(
132+
f"{BASE_URL}/core/virtual-machines/{vm_id}/attach-callback",
133+
json={"url": callback_url}
134+
)
135+
response.raise_for_status()
136+
data = response.json()
137+
138+
if data.get("status"):
139+
console.print(f"[green]Callback attached to '{vm_name}' (ID: {vm_id})[/green]")
140+
return True
141+
else:
142+
console.print(f"[red]Failed for '{vm_name}': {data.get('message', 'Unknown error')}[/red]")
143+
return False
144+
except httpx.HTTPStatusError as e:
145+
console.print(f"[red]HTTP error for '{vm_name}': {e.response.status_code}[/red]")
146+
return False
147+
148+
149+
def display_vms(vms: list[dict], title: str = "Virtual Machines") -> None:
150+
"""Display VMs in a formatted table."""
151+
table = Table(title=title)
152+
table.add_column("ID", style="cyan", justify="right")
153+
table.add_column("Name", style="green")
154+
table.add_column("Status", style="yellow")
155+
table.add_column("Flavor", style="blue")
156+
table.add_column("Environment", style="magenta")
157+
table.add_column("IP", style="white")
158+
159+
for vm in vms:
160+
ip = vm.get("floating_ip") or vm.get("fixed_ip") or "-"
161+
table.add_row(
162+
str(vm.get("id", "-")),
163+
vm.get("name", "-"),
164+
vm.get("status", "-"),
165+
vm.get("flavor", {}).get("name", "-") if isinstance(vm.get("flavor"), dict) else vm.get("flavor_name", "-"),
166+
vm.get("environment", {}).get("name", "-") if isinstance(vm.get("environment"), dict) else vm.get("environment_name", "-"),
167+
ip,
168+
)
169+
170+
console.print(table)
171+
172+
173+
def display_vm_details(vm: dict) -> None:
174+
"""Display detailed information about a single VM."""
175+
# Basic info table
176+
table = Table(title=f"VM Details: {vm.get('name', 'Unknown')}", show_header=False)
177+
table.add_column("Field", style="cyan")
178+
table.add_column("Value", style="white")
179+
180+
table.add_row("ID", str(vm.get("id", "-")))
181+
table.add_row("Name", vm.get("name", "-"))
182+
table.add_row("Status", f"[{'green' if vm.get('status') == 'ACTIVE' else 'yellow'}]{vm.get('status', '-')}[/]")
183+
table.add_row("Power State", vm.get("power_state", "-"))
184+
table.add_row("VM State", vm.get("vm_state", "-"))
185+
186+
# Flavor info
187+
flavor = vm.get("flavor", {})
188+
if isinstance(flavor, dict):
189+
table.add_row("Flavor", flavor.get("name", "-"))
190+
table.add_row(" - CPU", str(flavor.get("cpu", "-")))
191+
table.add_row(" - RAM", f"{flavor.get('ram', '-')} GB")
192+
table.add_row(" - Disk", f"{flavor.get('disk', '-')} GB")
193+
table.add_row(" - GPU", f"{flavor.get('gpu', '-')} x {flavor.get('gpu_name', '-')}")
194+
else:
195+
table.add_row("Flavor", vm.get("flavor_name", "-"))
196+
197+
# Environment
198+
env = vm.get("environment", {})
199+
if isinstance(env, dict):
200+
table.add_row("Environment", env.get("name", "-"))
201+
table.add_row("Region", env.get("region", "-"))
202+
else:
203+
table.add_row("Environment", vm.get("environment_name", "-"))
204+
205+
# Network
206+
table.add_row("Floating IP", vm.get("floating_ip") or "-")
207+
table.add_row("Fixed IP", vm.get("fixed_ip") or "-")
208+
209+
# Timestamps
210+
table.add_row("Created", vm.get("created_at", "-"))
211+
table.add_row("Updated", vm.get("updated_at", "-"))
212+
213+
console.print(table)
214+
215+
# Show raw JSON if debug mode
216+
if DEBUG:
217+
console.print("\n[dim]Raw JSON:[/dim]")
218+
console.print(Syntax(json.dumps(vm, indent=2, default=str), "json", theme="monokai"))
219+
220+
221+
def action_list_vms(client: httpx.Client) -> None:
222+
"""List all VMs."""
223+
console.print("\n[dim]Fetching VMs...[/dim]")
224+
vms = list_vms(client)
225+
226+
if not vms:
227+
console.print("[yellow]No virtual machines found.[/yellow]")
228+
return
229+
230+
display_vms(vms, "All Virtual Machines")
231+
console.print(f"\n[bold]Found {len(vms)} VM(s)[/bold]")
232+
233+
234+
def action_get_vm_status(client: httpx.Client) -> None:
235+
"""Get status of a specific VM by ID."""
236+
vm_id_str = Prompt.ask("Enter VM ID")
237+
try:
238+
vm_id = int(vm_id_str)
239+
except ValueError:
240+
console.print("[red]Invalid VM ID. Must be an integer.[/red]")
241+
return
242+
243+
console.print(f"\n[dim]Fetching VM {vm_id}...[/dim]")
244+
vm = get_vm_by_id(client, vm_id)
245+
246+
if vm:
247+
display_vm_details(vm)
248+
249+
250+
def action_delete_vms(client: httpx.Client) -> None:
251+
"""Delete VMs interactively."""
252+
console.print("\n[dim]Fetching VMs...[/dim]")
253+
vms = list_vms(client)
254+
255+
if not vms:
256+
console.print("[yellow]No virtual machines found.[/yellow]")
257+
return
258+
259+
display_vms(vms, "All Virtual Machines")
260+
console.print(f"\n[bold]Found {len(vms)} VM(s)[/bold]\n")
261+
262+
if Confirm.ask("[red]Delete ALL VMs without confirmation for each?[/red]", default=False):
263+
deleted = 0
264+
for vm in vms:
265+
if delete_vm(client, vm["id"], vm["name"]):
266+
deleted += 1
267+
console.print(f"\n[bold green]Deleted {deleted}/{len(vms)} VMs[/bold green]")
268+
return
269+
270+
console.print("\n[dim]For each VM: [y]es to delete, [n]o to skip, [a]ll to delete remaining, [q]uit[/dim]\n")
271+
272+
deleted = 0
273+
skipped = 0
274+
delete_all = False
275+
276+
for vm in vms:
277+
vm_id = vm["id"]
278+
vm_name = vm["name"]
279+
status = vm.get("status", "unknown")
280+
ip = vm.get("floating_ip") or vm.get("fixed_ip") or "no IP"
281+
282+
if delete_all:
283+
if delete_vm(client, vm_id, vm_name):
284+
deleted += 1
285+
continue
286+
287+
console.print(f"[bold]{vm_name}[/bold] (ID: {vm_id}, Status: {status}, IP: {ip})")
288+
choice = Prompt.ask(" [red]Delete[/red] this VM?", choices=["y", "n", "a", "q"], default="n")
289+
290+
if choice == "q":
291+
console.print("[yellow]Quitting...[/yellow]")
292+
break
293+
elif choice == "a":
294+
delete_all = True
295+
if delete_vm(client, vm_id, vm_name):
296+
deleted += 1
297+
elif choice == "y":
298+
if delete_vm(client, vm_id, vm_name):
299+
deleted += 1
300+
else:
301+
skipped += 1
302+
console.print(" [dim]Skipped[/dim]")
303+
304+
console.print(f"\n[bold]Summary:[/bold] Deleted {deleted}, Skipped {skipped}")
305+
306+
307+
def action_set_callbacks(client: httpx.Client) -> None:
308+
"""Set callback URL for VMs."""
309+
console.print("\n[dim]Fetching VMs...[/dim]")
310+
vms = list_vms(client)
311+
312+
if not vms:
313+
console.print("[yellow]No virtual machines found.[/yellow]")
314+
return
315+
316+
display_vms(vms, "All Virtual Machines")
317+
console.print(f"\n[bold]Found {len(vms)} VM(s)[/bold]\n")
318+
319+
# Get token and build callback URL
320+
token = get_callback_token()
321+
callback_url = build_callback_url(token)
322+
323+
# Show URL with masked token for security
324+
masked_url = f"{CALLBACK_URL_BASE}?token=****{token[-4:]}" if len(token) > 4 else f"{CALLBACK_URL_BASE}?token=****"
325+
console.print(f"[cyan]Callback URL: {masked_url}[/cyan]\n")
326+
327+
if Confirm.ask(f"[yellow]Set callback for ALL {len(vms)} VMs?[/yellow]", default=True):
328+
success = 0
329+
for vm in vms:
330+
if attach_callback(client, vm["id"], vm["name"], callback_url):
331+
success += 1
332+
console.print(f"\n[bold green]Callback set for {success}/{len(vms)} VMs[/bold green]")
333+
else:
334+
console.print("[dim]Cancelled[/dim]")
335+
336+
337+
def show_menu() -> str:
338+
"""Display main menu and get user choice."""
339+
console.print("\n[bold]Available Actions:[/bold]")
340+
console.print(" [cyan]1[/cyan] - List all VMs")
341+
console.print(" [cyan]2[/cyan] - Get VM status by ID")
342+
console.print(" [cyan]3[/cyan] - Delete VMs (interactive)")
343+
console.print(" [cyan]4[/cyan] - Set callback URL for all VMs")
344+
console.print(" [cyan]q[/cyan] - Quit")
345+
return Prompt.ask("\nSelect action", choices=["1", "2", "3", "4", "q"], default="1")
346+
347+
348+
def main():
349+
global DEBUG
350+
DEBUG = "--debug" in sys.argv
351+
352+
console.print(Panel.fit(
353+
"[bold blue]Hyperstack API Debugger[/bold blue]\n"
354+
"[dim]Debug and manage Hyperstack virtual machines[/dim]",
355+
border_style="blue"
356+
))
357+
358+
api_key = get_api_key()
359+
360+
headers = {
361+
"api_key": api_key,
362+
"accept": "application/json",
363+
}
364+
365+
with httpx.Client(headers=headers, timeout=30.0) as client:
366+
while True:
367+
choice = show_menu()
368+
369+
if choice == "q":
370+
console.print("[dim]Goodbye![/dim]")
371+
break
372+
elif choice == "1":
373+
action_list_vms(client)
374+
elif choice == "2":
375+
action_get_vm_status(client)
376+
elif choice == "3":
377+
action_delete_vms(client)
378+
elif choice == "4":
379+
action_set_callbacks(client)
380+
381+
382+
if __name__ == "__main__":
383+
try:
384+
main()
385+
except KeyboardInterrupt:
386+
console.print("\n[yellow]Interrupted[/yellow]")
387+
sys.exit(130)
388+
except httpx.HTTPStatusError as e:
389+
console.print(f"[red]HTTP error: {e.response.status_code} - {e.response.text}[/red]")
390+
sys.exit(1)

0 commit comments

Comments
 (0)