46 Commits

Author SHA1 Message Date
0df2fccc62 Update README.md
finally, first picture in readme!
2025-09-24 16:54:17 +03:00
OVERLORD7F
abf36d8661 Merge branch 'main' of https://github.com/OVERLORD7F/SVMU 2025-09-24 16:51:05 +03:00
OVERLORD7F
8141afb769 main menu pic 2025-09-24 16:50:46 +03:00
bdf81db7e6 Update README.md
Updated readme to reflect changes in 0.4-dev
2025-09-24 16:25:39 +03:00
43d0cd06b6 Update README.md
Updated readme to reflect changes in 0.4-dev
2025-09-24 16:23:13 +03:00
OVERLORD7F
cf3e71add4 Updated readme to reflect changes in 0.4-dev 2025-09-24 16:15:28 +03:00
OVERLORD7F
050d501900 Merge branch 'main' of https://github.com/OVERLORD7F/SVMU 2025-09-24 15:55:37 +03:00
OVERLORD7F
29af815ad3 0.4-dev
Added config profiles #23 (Massive changes in caonfig_data_import)
Removed example config #24
Fixed #25
Added current node display #26
Tested ISO auto-mount: Currently attached iso will be swapped (if any present)
Unified promts styling & other menu improvements
2025-09-24 15:47:32 +03:00
OVERLORD7F
18f13d94ef - Promts no longer case sensetive
- hopefully removed circular import and warnings with get_iso_name / get_vm_name
2025-09-18 15:59:44 +03:00
OVERLORD7F
6c272e46ab Implemented #22 2025-09-17 16:46:04 +03:00
OVERLORD7F
2210746bec and this one 2025-09-15 17:45:24 +03:00
OVERLORD7F
0f6e17a73c - option to auto-attach ISO to VMs #20
- added splash screen (ASCII ART) option to skip #21
- Implemented disk options as variables from config #19
- Related changes in config creation / editing menus
2025-09-15 17:44:55 +03:00
OVERLORD7F
1a92e86704 - added random splash screen on startup (testing)
- branding changes in menus
- ver. now stored in variable
2025-09-11 16:27:26 +03:00
OVERLORD7F
162a38be6d ver 6.5.7 tested - works! 2025-09-04 16:30:58 +03:00
27d85f4d1a Update README.md 2025-05-30 12:19:29 +03:00
OVERLORD7F
f3734432a7 Added option to write selected VMs by tag (#10) to сonfig 2025-05-30 12:12:33 +03:00
OVERLORD7F
72dccaa206 Default values for Courses-Space-VM section (fix #18) 2025-05-30 11:28:59 +03:00
Seting-dev
daba29f208 Filter VM UUIDs #10 (#17)
* is it my?

* Issues #8 Config Edit changes

* Patch #8

* vm_tags

* Changed

* deleted

* deleted conf

* patch to domain sub menu
2025-05-29 17:19:43 +03:00
OVERLORD7F
1f4c614c28 Show example config #14 2025-05-29 15:44:01 +03:00
OVERLORD7F
7867b0ea23 Added options in config for courses #16 2025-05-29 15:20:29 +03:00
OVERLORD7F
dae4bf33a0 Merge branch 'main' of https://github.com/OVERLORD7F/SpaceVM_VM_Utility 2025-05-29 13:30:01 +03:00
OVERLORD7F
d2383dea4f - Progress bars for disk delete / create actions
- Changed output for disk edit menu
2025-05-29 13:28:48 +03:00
OVERLORD
433854b36f Update README.md
updated config usage
2025-05-28 17:09:53 +03:00
OVERLORD7F
71a7c38c27 Expanding config-related functions
- New function change_vm_uuids
- New function change_data_pool
2025-05-28 16:59:38 +03:00
OVERLORD7F
3ca5404b81 LEEETS GOOOO <50 lines !!1
- Ping / API key checks are back! #13
- Changes in config import
- Moved everything in main to menu loop because of reasons  #2
- New function check_config
- Currently selected pools / vms .. are shown in main menu #12
- Renamed a few functions
2025-05-28 12:45:07 +03:00
OVERLORD7F
2ccde25b6e - Finally, proper config file is here!
- Small changes in menus
2025-05-26 17:52:12 +03:00
Seting-dev
96c6e29c00 Config Edit changes #8 (#11)
* Issues #8 Config Edit changes

* Patch #8
2025-05-26 10:28:01 +03:00
OVERLORD
7e986834f6 Update README.md 2025-05-21 15:13:41 +03:00
OVERLORD7F
291e9cfe75 Complete menus overhaul 2025-05-21 14:12:37 +03:00
OVERLORD7F
d7477da9b6 - Beggining of implementing new menus and text using "Rich" library
btw main.py is ~ 50 lines now 0_o
2025-05-16 17:53:57 +03:00
OVERLORD7F
da756a8ba6 - Changed "disk edit mode" into function and moved to new module
- Changed config sub menu into function and moved to config_data_import module
- New function config_show
- Fixed create disk option in disk edit mode
- A few formatting changes in menus
2025-05-16 12:17:51 +03:00
OVERLORD7F
b6fb2b14e6 - Introducing power-on-check function.
All disk-related operations are available ONLY if VM is powered off.
2025-05-16 11:09:27 +03:00
OVERLORD
6b56b75189 Merge pull request #7 from Seting-dev/main
edit data pools api
2025-05-15 17:21:29 +03:00
OVERLORD
331b8f6935 Update data_pools_api.py
WOW SO MUCH COMMITED
2025-05-15 17:20:20 +03:00
Seting-dev
6c1759549c edit data pools api 2025-05-15 17:17:33 +03:00
OVERLORD7F
81554db41a removed vm_info_short 2025-05-15 15:44:39 +03:00
OVERLORD7F
54328f4e4a LEETS GOOOOOOOO
main is less then 100 lines !!!!
Code cleanup, all functions moved from main.
Small changes in main menu

vm_info_short moved to domain_api
2025-05-15 15:41:33 +03:00
OVERLORD7F
dccb5975e6 Merge branch 'main' of https://github.com/OVERLORD7F/SpaceVM_VM_Utility 2025-05-15 11:51:06 +03:00
OVERLORD7F
28e9bb8ea4 changes in importing config 2025-05-15 11:46:28 +03:00
Seting-dev
9525c4e2db Update main.py
test
2025-05-15 10:58:07 +03:00
Seting-dev
e7d2e0e829 check 2025-05-15 10:53:03 +03:00
Seting-dev
2f7a0826bf test1
test2
test3
2025-05-15 10:48:51 +03:00
Seting-dev
a885c549cc Merge branch 'main' of https://github.com/OVERLORD7F/SpaceVM_VM_Utility 2025-05-15 10:21:48 +03:00
OVERLORD
22f3d3fe64 Fixed issue #2 , Expanded config_edit function 2025-05-14 10:53:34 +03:00
Seting-dev
b2e361f032 Хуйня 2025-05-13 17:41:42 +03:00
OVERLORD
013d352bde Update README.md 2025-05-13 14:02:42 +03:00
10 changed files with 1172 additions and 197 deletions

View File

@@ -1,24 +1,78 @@
# SpaceVM_VM_Utility
# SpaceVM Utility (SVMU)
Utility to manage Virtual Machines in SpaceVM.
Written in python, uses [SpaceVM API](https://spacevm.ru/docs/6.5/api/) to collect and manage existing Virtual Machines in your SpaceVM cluster.
_For now, this utility is focused on managing virtual disks_
![Utility Main Menu](https://github.com/OVERLORD7F/SVMU/blob/main/assets/images/svmu-main-menu.png)
_Works with SpaceVM 6.5.5+_
>[!NOTE]
>_This utility is focused on managing virtual disks_<br>
>_Works with SpaceVM 6.5.5 / 6.5.6 / 6.5.7_
# Config File
Config file contains all necessary data for utility and has to be placed in the same directory as Utility itself.
# Requirements
- Fully setup SpaceVM cluster with VMs
- SpaceVM Utility and SpaceVM cluster should be in LAN
- Obtain your [API Key](https://spacevm.ru/docs/latest/base/operator_guide/security/users/#_14)
>[!WARNING]
> Utility is only tested on Windows 10
- For Windows 10 - [New Microsoft Terminal](https://github.com/microsoft/terminal) is highly recommended (correct colors, menus, etc)
You can populate config within Utility Main Menu.
# Utility usage
Clone repository or use compiled .exe from [Releases Tab](https://github.com/OVERLORD7F/SpaceVM_VM_Utility/releases)
For manual input see format below:
## Config / Profile File
Directory _./profiles_ contains all configured profiles with necessary data for utility.
This directory will be placed in the same directory as Utility itself.
>[!TIP]
>_You can create profiles and change specific options within the Utility._
```
Controller IP Address
API Integration Key
Data Pool UUID
VM UUID #1
VM UUID #2
VM UUID #3
...
[General]
#Master Controller IP of your cluster
#Has to be accessible for a machine, which will be executing this Utility
controller_ip = 10.20.30.44
#Integration API Key
(how to get your key - https://spacevm.ru/docs/latest/base/operator_guide/security/users/#_14 )
# do not specify JWT tag with your key!
api_key =
#skip start up splash screen (ASCII art)
skip_startup_splash = no
#loads this profile on utility startup by default
#only one profile could be loaded by default
load_by_default = false
[Data_Pool]
#Data pool which will be used for utility operations
#(Targeted storage for new vDisks)
data_pool_uuid =
[VM_Options]
#Select interface which will be used in virtual disk creation.
#Available options: virtio / ide / scsi / sata
disk_interface = virtio
#Select allocation type for virtual disks
#Available options: none / falloc / full / metadata
preallocation = falloc
#Specify uuid of iso you wish to automatically mount to Virtual Machines during operations (Courses)
#This step is skipped if "none" provided
iso_uuid = none
[Courses-Space-VM]
#Set vDisk size for "Prepare VMs for Courses" option
disk1 =
disk2 =
disk3 =
[VM_List]
#Selected VMs which will be used for utility operations
#How to find UUID:
#List all available VMs in Utility Main Menu (Option 6)
#Use https://spacevm.ru/docs/latest/cli/space/vm/info/ or copy UUID from web panel
uuid_1 =
uuid_2 =
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -1,26 +1,50 @@
#from main import base_url , api_key , requests
import requests
import os
from rich.prompt import Prompt
from rich.console import Console
from rich.panel import Panel
from rich.align import Align
def cluster_info(base_url , api_key): #output short clusters overview
url= f"http://{base_url}/api/clusters"
response = requests.get(url , headers={'Authorization' : api_key})
def cluster_info(base_url, api_key): # output short clusters overview
url = f"http://{base_url}/api/clusters"
response = requests.get(url, headers={'Authorization': api_key})
console = Console()
if response.status_code == 200:
cluster_info = response.json()
results_cluster_info = cluster_info['results']
print("\nShort clusters overview:")
print(f"\nClusters total: {cluster_info['count']}")
print("-" * 51)
os.system('cls' if os.name == 'nt' else 'clear')
console.rule("[bold cyan]Short Clusters Overview")
console.print(f"[bold]Clusters total:[/] {cluster_info['count']}\n")
panels = []
for x in results_cluster_info:
print(f"\nCluster Name: {x['verbose_name']} ({x['status']})")
print(f"Nodes: {x['nodes_count']}")
print(f"Total CPU: {x['cpu_count']} Cores || CPU Usage: {round(x['cpu_used_percent_user'] , 2)}%")#output is rounded by 2
print(f"Total RAM: {int(x['memory_count']/1024)}GB || RAM Usage: {round(x['mem_used_percent_user'] , 2)}%") #RAM pretty output = mb-to-gb + set 'int' to remove .0
print("-" * 51)
panel_content = (
f"[bold]Nodes:[/] {x['nodes_count']}\n"
f"[bold]Total CPU:[/] {x['cpu_count']} Cores / [bold]CPU Usage:[/] {round(x['cpu_used_percent_user'], 2)}%\n"
f"[bold]Total RAM:[/] {int(x['memory_count']/1024)} GB / [bold]RAM Usage:[/] {round(x['mem_used_percent_user'], 2)}%"
)
panel = Panel(
Align.left(panel_content),
title=f"[bold gold3]{x['verbose_name']}[/] [red]({x['status']})[/]",
border_style="magenta",
expand=False
)
panels.append(panel)
console.print(*panels, sep="\n")
else:
print(f"Failed to retrieve data {response.status_code}")
console.print(f"[red]Failed to retrieve data {response.status_code}[/]")
Prompt.ask("[green_yellow bold]ENTER - return to Main Menu.. :right_arrow_curving_down:")
os.system('cls' if os.name == 'nt' else 'clear')
def check_api_key(base_url, api_key): # test api key and show spaceVM version
url = f"http://{base_url}/api/controllers/base-version/"
response = requests.get(url, headers={'Authorization': api_key})
console = Console()
if response.status_code == 200:
cluster_info = response.json()
version = cluster_info['version']
console.print(f"[bold green]Successfully conected to SpaceVM v{version}")
else:
console.print(f"[bold red]{response.status_code}[/]")
return response.status_code

504
config_data_import.py Normal file
View File

@@ -0,0 +1,504 @@
import os
import subprocess
import configparser
from cluster_api import *
from domain_api import *
from data_pools_api import *
from rich import print
from rich.panel import Panel
from rich.console import Console , Align
from rich.prompt import Prompt
console = Console()
def config_menu(base_url, api_key, config_relative_path):
cls()
config_menu_options="[gold bold][1] [grey53 italic]Show current profile configuration\n[/]\
\n[gold bold][2] [grey53 italic]Setup new profile[/]\n \
\n[gold bold][3] [grey53 italic]Switch profile[/]\n\
\n[gold bold][4] [grey53 italic]Delete profile[/]\n\
\n[gold bold][5] [grey53 italic]Set default profile[/]\n\
\n[gold bold][6] [grey53 italic]Change selected data pool[/]\n\
\n[gold bold][7] [grey53 italic]Change selected VMs[/]\n\
\n[gold bold][8] [grey53 italic]Change ISO UUID (auto-mount)[/]\n\
\n[gold bold][9] [grey53 italic]Skip start-up splash[/]\n\
\n\n[green_yellow bold]ENTER - return to Main Menu[/]"
config_menu_options=Align.center(config_menu_options, vertical="middle")
console = Console()
console.print(Panel(config_menu_options, title="[gold bold]SpaceVM Utility - Utility Configuration" , border_style="magenta" , width=150 , padding = 2))
sub_choice = console.input("[bold yellow]\n>>> [/]")
needs_reload = False
if sub_choice == "1":
config_show(config_relative_path)
return config_menu(base_url, api_key, config_relative_path)
if sub_choice == "2":
new_path = create_new_profile()
if new_path:
return new_path
if sub_choice == "3":
new_profile_path = switch_profile()
if new_profile_path: # If we got a new profile path
return new_profile_path # Return it to main.py
if sub_choice == "4":
delete_profile(config_relative_path)
if sub_choice == "5":
set_default_profile()
if sub_choice == "6":
change_data_pool(base_url, api_key, config_relative_path)
needs_reload = True
if sub_choice == "7":
change_vm_uuids(config_relative_path)
needs_reload = True
if sub_choice == "8":
change_iso_uuid(config_relative_path)
needs_reload = True
if sub_choice == "9":
change_startup_option(config_relative_path)
needs_reload = True
return needs_reload
def config_show(config_relative_path):
cls()
console.rule(title = "Current configuration" , align="center" , style="yellow")
with open(config_relative_path, "r") as f:
print(f.read())
console.rule(style="yellow")
Prompt.ask("[green_yellow bold]ENTER - return to Utility Configuration.. :right_arrow_curving_down:")
def config_import(config_relative_path):
config = configparser.ConfigParser()
config.read(config_relative_path)
skip_startup_splash = config.get('General', 'skip_startup_splash')
base_url = config.get('General', 'controller_ip')
api_key = "jwt " + config.get('General', 'api_key') #That was realy obvious DACOM >:C
data_pool_uuid = config.get('Data_Pool', 'data_pool_uuid')
vm_list = []
if 'VM_List' in config:
for key, value in config['VM_List'].items():
vm_list.append(value)
#importing VM_Options
if config.has_section('VM_Options'):
iso_uuid = config.get('VM_Options' , 'iso_uuid')
disk_interface = config.get('VM_Options' , 'disk_interface')
preallocation = config.get('VM_Options' , 'preallocation')
iso_name=get_iso_name(base_url, api_key, iso_uuid)
else:
console.print("[bold yellow]Applying default values to Virtual Machine Options")
iso_uuid = "none"
disk_interface = "virtio"
preallocation = "falloc"
iso_name= "none"
config = configparser.ConfigParser() #writing default values to config
config["VM_Options"] = {
"disk_interface": "virtio",
"preallocation": "falloc",
"iso_uuid": "none",
}
with open(config_relative_path, "a") as configfile: # appending to existing config file
config.write(configfile)
#importing disk sizes for SpaceVM courses
if config.has_section('Courses-Space-VM'):
disk1_size = config.get('Courses-Space-VM', 'disk1')
disk2_size = config.get('Courses-Space-VM', 'disk2')
disk3_size = config.get('Courses-Space-VM', 'disk3')
else:
console.print("[bold yellow]Applying default values to Disk sizes for Courses")
disk1_size, disk2_size, disk3_size = 10, 20, 20 #applying default values for courses
config = configparser.ConfigParser() #writing default values to config
config["Courses-Space-VM"] = {
"disk1": 10,
"disk2": 20,
"disk3": 20,
}
with open(config_relative_path, "a") as configfile: # appending to existing config file
config.write(configfile)
#get pretty name for selected data pool
data_pool_name = get_data_pool_name(base_url , api_key , data_pool_uuid)
#get pretty name for selected VMs
vm_names=[]
for x in vm_list:
vm_names.append(get_vm_name(base_url, api_key, x))
return skip_startup_splash, base_url, api_key, data_pool_uuid, data_pool_name, vm_list, vm_names, disk1_size, disk2_size, disk3_size, disk_interface, preallocation, iso_uuid, iso_name
def change_startup_option(config_relative_path):
cls()
#console.print("[yellow bold]Skip start-up splash ?")
new_value = Prompt.ask("[yellow bold]Skip start-up splash ?[/]", choices=["Y", "N"], default="N", case_sensitive=False)
if new_value == "Y" or new_value == "y":
startup_option = "yes"
if new_value == "N" or new_value == "n":
startup_option = "no"
new_value = Prompt.ask("[yellow bold]Skip start-up splash ?[/]", choices=["Y", "N"], default="N", case_sensitive=False)
if new_value == "Y" or new_value == "y":
startup_option = "yes"
if new_value == "N" or new_value == "n":
startup_option = "no"
config = configparser.ConfigParser()
config.read(config_relative_path)
if config.has_section('General'):
config.set('General', 'skip_startup_splash', startup_option)
config.set('General', 'skip_startup_splash', startup_option)
with open(config_relative_path, 'w') as config_file:
config.write(config_file)
console.print(f"[green bold]Option set to: {new_value}")
else:
console.print("[red bold]No section 'General' in config file")
def change_data_pool(base_url, api_key, config_relative_path): #change selected data pool in config
cls()
show_data_pools(base_url, api_key)
new_data_pool_uuid = console.input("[bold yellow]Type NEW Data Pool UUID: [/]")
config = configparser.ConfigParser()
config.read(config_relative_path)
if config.has_section('Data_Pool'):
config.set('Data_Pool', 'data_pool_uuid', new_data_pool_uuid)
with open(config_relative_path, 'w') as config_file:
config.write(config_file)
else:
print("No 'Data_Pool' section in config file..")
config_show(config_relative_path)
def change_iso_uuid(config_relative_path):
cls()
new_iso_uuid = console.input("[bold yellow]Type ISO UUID: [/]")
config = configparser.ConfigParser()
config.read(config_relative_path)
if config.has_section('VM_Options'):
config.set('VM_Options', 'iso_uuid', new_iso_uuid)
with open(config_relative_path, 'w') as config_file:
config.write(config_file)
else:
print("No 'VM_Options' section in config file..")
config_show(config_relative_path)
def change_vm_uuids(config_relative_path): #change selected VM uuids in config
config = configparser.ConfigParser()
config.read(config_relative_path)
# Remove old VM_List section if it exists, then add a fresh one
if config.has_section('VM_List'):
config.remove_section('VM_List')
config.add_section('VM_List')
cls()
console.print("[yellow bold]Type new VM UUIDs one by one [red bold](ENTER to stop)[/] ")
x = 0
while True:
vm_input = console.input("[bold yellow]>> [/]" )
if not vm_input:
break
x += 1
config.set('VM_List', f'uuid_{x}', vm_input)
with open(config_relative_path, 'w') as configfile:
config.write(configfile)
console.print("[green bold]VM UUIDs have been updated in config :pencil:")
Prompt.ask("[green_yellow bold]Press ENTER to proceed.. :right_arrow_curving_down:")
config_show(config_relative_path)
def config_edit(config_relative_path):
read_input = Prompt.ask("[bold yellow]Create new config file?[/]", choices=["Y", "N"], default="N", case_sensitive=False)
menu_choice = str(read_input)
if menu_choice == "Y" or menu_choice == "y":
base_url = input("Type SpaceVM Controller IP: ")
while check_ping(base_url) != True:
base_url = console.input("[bold red]No response.\nCheck and type SpaceVM Controller IP again: [/]")
api_key = input("Type your API Key: ")
while check_api_key(base_url, "jwt " + api_key) != 200:
api_key = console.input("[bold red]Check and type SpaceVM Controller API Key again: [/]")
show_data_pools(base_url, "jwt " + api_key)
data_pool_uuid = input("Type Data Pool UUID you wish to use: ")
config = configparser.ConfigParser()
config["General"] = {
"controller_ip": base_url,
"api_key": api_key,
"skip_startup_splash": "no",
}
config["Data_Pool"] = {"data_pool_uuid": data_pool_uuid}
#disk_interface=input("Specify preffered disk interface (virtio / ide / scsi / sata): ")
#preallocation=input("Specify allocation type for virtual disks (none / falloc / full / metadata): ")
#iso_uuid=input("Specify ISO uuid you wish to auto-mount during operations(none - skip this step): ")
#config["VM_Options"] = {
# "disk_interface": disk_interface,
# "preallocation": preallocation,
# "iso_uuid": iso_uuid
#}
with open(config_relative_path, "w") as configfile: #writing everything from above to config file
config.write(configfile)
print("Type VM UUIDs one by one (input ENTER to stop)")
with open(config_relative_path, "a") as file:
file.write("[VM_List]\n") #manually writing section for VMs
vm_input = []
x = 0
while vm_input != "":
vm_input = input(">> ")
if vm_input:
x += 1
file.write(f"uuid_{x} = {vm_input}\n")
console.print("[green bold]VM UUIDs have been written in config :pencil:")
console.print("[green bold]Configuration completed ! :white_check_mark:")
Prompt.ask("[green_yellow bold]Press ENTER to proceed.. :right_arrow_curving_down:")
cls()
def check_config(config_relative_path):
"""Check if config exists and is valid"""
# Only check if the file is empty and needs to be removed
if os.path.exists(config_relative_path) and os.path.getsize(config_relative_path) == 0:
console.print("[red bold]Config file is empty!")
os.remove(config_relative_path) # Remove empty file
def cls():
os.system('cls' if os.name=='nt' else 'clear')
def check_ping(base_url):
DNULL = open(os.devnull, 'w')
if os.name == 'nt':
status = subprocess.call(["ping","-n","1",base_url],stdout = DNULL)
else:
status = subprocess.call(["ping","-c","1",base_url],stdout = DNULL)
if status == 0:
return True
else:
return False
def create_profiles_dir():
"""Create profiles directory if it doesn't exist"""
profiles_dir = os.path.join(os.getcwd(), 'profiles')
if not os.path.exists(profiles_dir):
os.makedirs(profiles_dir)
return profiles_dir
def get_available_profiles():
"""Get list of available profile names"""
profiles_dir = create_profiles_dir()
profiles = []
for filename in os.listdir(profiles_dir):
if filename.endswith('.conf'):
profiles.append(filename[:-5]) # Remove .conf extension
return profiles
def create_new_profile():
"""Create a new profile configuration"""
cls()
profiles_dir = create_profiles_dir()
profile_name = Prompt.ask("[yellow bold]Enter new profile name")
profile_path = os.path.join(profiles_dir, f"{profile_name}.conf")
if os.path.exists(profile_path):
console.print("[red bold]Profile already exists!")
return
base_url = console.input("[bold yellow]Type SpaceVM Controller IP: [/]")
while check_ping(base_url) != True:
base_url = console.input("[bold red]No response.\nCheck and type SpaceVM Controller IP again: [/]")
api_key = console.input("[bold yellow]Type your API Key: [/]")
while check_api_key(base_url, "jwt " + api_key) != 200:
api_key = console.input("[bold red]Check and type SpaceVM Controller API Key again: [/]")
show_data_pools(base_url, "jwt " + api_key)
data_pool_uuid = console.input("[bold yellow]Type Data Pool UUID you wish to use: [/]")
config = configparser.ConfigParser()
config["General"] = {
"controller_ip": base_url,
"api_key": api_key,
"skip_startup_splash": "no",
}
config["Data_Pool"] = {"data_pool_uuid": data_pool_uuid}
with open(profile_path, "w") as configfile:
config.write(configfile)
console.print("[yellow bold]Type VM UUIDs one by one (input ENTER to stop):")
with open(profile_path, "a") as file:
file.write("[VM_List]\n")
x = 0
while True:
vm_input = console.input("[bold yellow]>> [/]")
if not vm_input:
break
x += 1
file.write(f"uuid_{x} = {vm_input}\n")
console.print("[green bold]Configuration completed! :white_check_mark:")
# Prompt to switch to the newly created profile now
switch_now = Prompt.ask("[yellow bold]Switch to new profile now?", choices=["Y", "N"] , default="Y", case_sensitive=False)
if switch_now.lower() == "y" or switch_now.lower() == "Y":
# Also ask if user wants to set this profile as default
set_default = Prompt.ask("[yellow bold]Set new profile as default?", choices=["Y", "N"], default="N", case_sensitive=False)
if set_default.lower() == "y" or set_default.lower() == "Y":
# Use existing set_default_profile logic: mark this profile as default
profiles_dir = create_profiles_dir()
profiles = get_available_profiles()
for p in profiles:
p_path = os.path.join(profiles_dir, f"{p}.conf")
cfg = configparser.ConfigParser()
cfg.read(p_path)
if cfg.has_section('General'):
cfg.set('General', 'load_by_default', 'false')
with open(p_path, 'w') as f:
cfg.write(f)
# set new profile as default
cfg = configparser.ConfigParser()
cfg.read(profile_path)
if not cfg.has_section('General'):
cfg.add_section('General')
cfg.set('General', 'load_by_default', 'true')
with open(profile_path, 'w') as f:
cfg.write(f)
return profile_path
def switch_profile():
"""Switch to a different profile."""
cls()
profiles = get_available_profiles()
if not profiles:
console.print("[red bold]No profiles found!")
return
console.print("[yellow bold]Available profiles:")
for i, profile in enumerate(profiles, 1):
console.print(f"[grey53]{i}. {profile}")
choice = Prompt.ask("[yellow bold]Select profile number: ", choices=[str(i) for i in range(1, len(profiles) + 1)])
selected_profile = profiles[int(choice) - 1]
# Get the path for the selected profile
selected_profile_path = os.path.join(create_profiles_dir(), f"{selected_profile}.conf")
if not os.path.exists(selected_profile_path):
console.print(f"[red bold]Profile '{selected_profile}' does not exist!")
return
# Return the new profile path to update in main.py
console.print(f"[green bold]Switched to profile: {selected_profile}")
return selected_profile_path # Return the new path instead of True
def delete_profile(current_profile_path):
"""Delete an existing profile"""
cls()
profiles = get_available_profiles()
if not profiles:
console.print("[red bold]No profiles found!")
return
console.print("[yellow bold]Available profiles:")
for i, profile in enumerate(profiles, 1):
console.print(f"[grey53]{i}. {profile}")
choice = Prompt.ask("[yellow bold]Select profile to delete", choices=[str(i) for i in range(1, len(profiles)+1)])
selected_profile = profiles[int(choice)-1]
selected_profile_path = os.path.join(create_profiles_dir(), f"{selected_profile}.conf")
if os.path.normpath(selected_profile_path) == os.path.normpath(current_profile_path):
console.print("[red bold]Cannot delete the currently active profile!")
Prompt.ask("[green_yellow bold]Press ENTER to return.. :right_arrow_curving_down:")
return
confirm = Prompt.ask(f"[red bold]Are you sure you want to delete {selected_profile}?", choices=["Y", "N"] , default="Y" , case_sensitive=False)
if confirm.upper() == "Y":
os.remove(os.path.join(os.getcwd(), 'profiles', f"{selected_profile}.conf"))
console.print(f"[green bold]Profile {selected_profile} deleted!")
def set_default_profile():
"""Set the selected profile as the default profile."""
cls()
profiles = get_available_profiles()
if not profiles:
console.print("[red bold]No profiles found!")
return
console.print("[yellow bold]Available profiles:")
for i, profile in enumerate(profiles, 1):
console.print(f"[grey53]{i}. {profile}")
choice = Prompt.ask("[yellow bold]Select profile number to set as default", choices=[str(i) for i in range(1, len(profiles) + 1)])
selected_profile = profiles[int(choice) - 1]
# Set all other profiles' default flag to false
profiles_dir = create_profiles_dir()
for profile in profiles:
profile_path = os.path.join(profiles_dir, f"{profile}.conf")
config = configparser.ConfigParser()
config.read(profile_path)
if config.has_section('General'):
config.set('General', 'load_by_default', 'false')
with open(profile_path, 'w') as f:
config.write(f)
# Set the selected profile as default
selected_profile_path = os.path.join(profiles_dir, f"{selected_profile}.conf")
config = configparser.ConfigParser()
config.read(selected_profile_path)
if not config.has_section('General'):
config.add_section('General')
config.set('General', 'load_by_default', 'true')
with open(selected_profile_path, 'w') as f:
config.write(f)
console.print(f"[green bold]Profile '{selected_profile}' set as default!")
def get_default_config_path():
"""Retrieve the path of the default profile configuration or prompt the user to select one."""
profiles_dir = create_profiles_dir()
profiles = get_available_profiles()
for profile in profiles:
profile_path = os.path.join(profiles_dir, f"{profile}.conf")
config = configparser.ConfigParser()
config.read(profile_path)
if config.has_section('General') and config.has_option('General', 'load_by_default'):
if config.getboolean('General', 'load_by_default'):
return profile_path
# If no default profile is found, prompt the user to select one
if profiles:
console.print("[yellow bold]No default profile found. Please select a profile to load:")
for i, profile in enumerate(profiles, 1):
console.print(f"[grey53]{i}. {profile}")
choice = Prompt.ask("[yellow bold]Select profile number", choices=[str(i) for i in range(1, len(profiles) + 1)])
selected_profile = profiles[int(choice) - 1]
return os.path.join(profiles_dir, f"{selected_profile}.conf")
console.print("[red bold]No profiles available. Please create a new profile.")
create_new_profile()
# Refresh the profiles list and directly return the newly created profile
profiles = get_available_profiles()
if profiles:
new_profile_path = os.path.join(profiles_dir, f"{profiles[-1]}.conf")
console.print(f"[green bold]Loaded newly created profile: {profiles[-1]}")
return new_profile_path
raise RuntimeError("Failed to create or load a profile.")
# config_relative_path will be set when passed from main.py

43
data_pools_api.py Normal file
View File

@@ -0,0 +1,43 @@
import requests
import os
from rich.prompt import Prompt
from rich.console import Console
from rich.panel import Panel
from rich.align import Align
def show_data_pools(base_url, api_key): # output data pool info
url = f"http://{base_url}//api/data-pools/"
response = requests.get(url, headers={'Authorization': api_key})
console = Console()
if response.status_code == 200:
data_pools = response.json()
results_data_pools_info = data_pools['results']
#os.system('cls' if os.name == 'nt' else 'clear')
console.rule("[bold cyan]Data Pools Overview")
console.print(f"[bold]Data pools total:[/] {data_pools['count']}\n")
panels = []
for x in results_data_pools_info:
panel_content = (
f"[bold]Type:[/] {x['type']}\n"
f"[bold]Used:[/] {round((x['free_space']/1024), 1)} Gb / {round((x['size'] / 1024), 1)} Gb\n"
f"[bold]UUID:[/] [italic]{x['id']}"
)
panel = Panel(
Align.left(panel_content),
title=f"[bold gold3]{x['verbose_name']}[/] [red]({x['status']})[/]",
border_style="magenta",
expand=False
)
panels.append(panel)
console.print(*panels, sep="\n")
else:
console.print(f"[red]Failed to retrieve data {response.status_code}[/]")
Prompt.ask("[green_yellow bold]ENTER - to proceed.. :right_arrow_curving_down:")
#translates data pool uuid to verbose_name
def get_data_pool_name(base_url, api_key, data_pool_uuid):
url = f"http://{base_url}//api/data-pools/{data_pool_uuid}/"
response = requests.get(url, headers={'Authorization': api_key})
if response.status_code == 200:
data_pool_name = response.json()
return (f"{data_pool_name['verbose_name']}")

76
disk_edit_mode.py Normal file
View File

@@ -0,0 +1,76 @@
import os, sys
import requests
from domain_api import *
from rich.prompt import Prompt
from rich.console import Console , Align
from rich.panel import Panel
def disk_edit_mode(base_url , api_key , data_pool_uuid , vm_uuids, disk1_size, disk2_size, disk3_size, disk_interface, preallocation, iso_uuid):
os.system('cls' if os.name=='nt' else 'clear')
diks_edit_menu_options="[gold bold][1] [grey53 italic]Delete vDisk by UUID\n[/grey53 italic] \
\n[gold bold][2] [grey53 italic]Delete ALL vDisks on selected Virtual Machine[/grey53 italic]\n \
\n[gold bold][3] [grey53 italic]Create Disk[/grey53 italic]\n \
\n[gold bold][4] [grey53 italic]Prepare VMs for Courses™[/grey53 italic]\n \
\n\n[green_yellow bold]ENTER - return to Main Menu"
diks_edit_menu_options = Align.center(diks_edit_menu_options, vertical="middle")
console = Console()
console.print(Panel(diks_edit_menu_options, title="[bold red]Disk Edit Mode" , border_style="magenta" , width=150 , padding = 2))
sub_choice = console.input("[bold yellow]\n>>> [/]")
if sub_choice == "1":
read_input = console.input("[bold yellow]Input vDisk uuid to delete: [/]" )
vdisk_uuid=str(read_input)
delete_disk(base_url , api_key , vdisk_uuid)
if sub_choice == "2":
print(vm_uuids)
select_uuids = int(console.input("[bold yellow]Select VM to delete disks from. \n Type VM uuid index number (from list above) to select: [/]")) - 1
vm_check_power(base_url , api_key , vm_uuids[select_uuids]) #power on check
domain_all_content = get_domain_all_content(base_url , api_key , vm_uuids[select_uuids])
disk_uuids = get_disk_uuids(base_url , api_key , domain_all_content)
for x in disk_uuids:
delete_disk(base_url , api_key , x)
console.print("[bold red]All attached vDisks has been deleted!")
if sub_choice == "3":
vdisk_size = str(console.input("[bold yellow]Enter disk size (GB): [/]"))
print(vm_uuids)
select_uuids = int(console.input("[bold yellow]Select VM to attach new disk. \n Type VM uuid index number (from list above) to select: [/]")) - 1
print(f"{vm_uuids[select_uuids]} - {data_pool_uuid} - {vdisk_size} ")
create_and_attach_disk(base_url , api_key , vm_uuids[select_uuids] , data_pool_uuid , vdisk_size , disk_interface, preallocation)
if sub_choice == "4":
os.system('cls' if os.name=='nt' else 'clear')
console.rule(title="[bold magenta]Preparing VMs for Courses" , align="center" , style="grey53" , characters = "=")
for y in vm_uuids: #power-on check
domain_uuid = y.strip('\n')
vm_check_power(base_url , api_key , domain_uuid)
for x in vm_uuids: # only for removing disks
domain_uuid = x.strip('\n')
domain_info = get_domain_info(base_url , api_key , domain_uuid)
domain_all_content = get_domain_all_content(base_url , api_key , domain_uuid)
vm_info(base_url , api_key , domain_uuid)
if domain_info:
disk_uuids = get_disk_uuids(base_url , api_key , domain_all_content)
for y in disk_uuids:
if not delete_disk(base_url , api_key , y): #if delete_disk returns False - aborting
console.print("[bold red] Aborting further operations.")
sys.exit(1)
for z in vm_uuids: # only for creating disks
domain_uuid = z.strip('\n')
vm_name = get_vm_name(base_url, api_key, domain_uuid)
console.print(f"\n[bold underline yellow]Creating and attaching disks to[/] [bright_cyan]{vm_name}:")
domain_info = get_domain_info(base_url , api_key , domain_uuid)
#domain_all_content = get_domain_all_content(base_url , api_key , domain_uuid)
if domain_info:
#iso_uuid="b95241c1-6134-4263-9da5-013459612eeb"
create_and_attach_disk(base_url , api_key , domain_uuid , data_pool_uuid, disk1_size, disk_interface, preallocation)
create_and_attach_disk(base_url , api_key , domain_uuid , data_pool_uuid, disk2_size, disk_interface, preallocation)
create_and_attach_disk(base_url , api_key , domain_uuid , data_pool_uuid, disk3_size, disk_interface, preallocation)
if iso_uuid == 'none':
console.print("[grey53 italic]iso_uuid was not specified. Skipping ISO auto-mount..[/]")
else:
attach_iso(base_url, api_key, domain_uuid, iso_uuid)
console.print("[bold green]\nDone. Happy virtualization :thumbs_up::thumbs_up:")
Prompt.ask("[green_yellow bold]ENTER - return to Main Menu.. :right_arrow_curving_down:")
os.system('cls' if os.name=='nt' else 'clear')

View File

@@ -1,11 +1,16 @@
# functions for working with domain-api
import requests
import requests, json
import secrets #for generating unique names
#from main import power_state
import os
import configparser
#from config_data_import import * hopefully removes circular import and warnings with get_iso_name / get_vm_name
from rich.console import Console , Align
from rich.columns import Columns
from rich.panel import Panel
from rich.prompt import Prompt, Confirm
from rich.progress import Progress, SpinnerColumn, TextColumn
console = Console() #necessary for pretty menus & output
power_state = ["Unknown" , "Off" , "Suspend" , "On"] #3 - on; 2 - suspend; 1 - off; 0 - unknown
@@ -20,7 +25,6 @@ def get_domain_info(base_url , api_key , domain_uuid):
print(f"Failed to retrieve data {response.status_code}")
def get_domain_all_content(base_url, api_key, domain_uuid):
url= f"http://{base_url}/api/domains/{domain_uuid}/all-content"
response = requests.get(url , headers={'Authorization' : api_key})
@@ -51,86 +55,284 @@ def get_disk_uuids(base_url , api_key , domain_all_content):
print("ERROR: unexpected data format")
return []
def delete_disk(base_url , api_key , vdisk_uuid):
url = f"http://{base_url}/api/vdisks/{vdisk_uuid}/remove/"
headers={
url = f"http://{base_url}/api/vdisks/{vdisk_uuid}/remove/"
headers={
"Authorization" : api_key,
"Content-Type" : "application/json",
}
payload= {
}
payload= {
"force": False,
"guaranteed": False,
"clean_type": "zero",
"clean_count": 1
}
}
with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}")) as progress:
task = progress.add_task("Deleting vDisk...", total=None)
response = requests.post(url , headers=headers, json=payload)
if response.status_code == 200:
print(f"vDisk {vdisk_uuid} successfully deleted")
return True
else:
print(f"ERROR deleting disk {vdisk_uuid} :\n {response.status_code} - {response.text}")
return False
progress.remove_task(task)
if response.status_code == 200:
console.print(f"[grey53 italic]{vdisk_uuid}[/] :wastebasket:")
return True
else:
#print(f"ERROR deleting disk {vdisk_uuid} :\n {response.status_code} - {response.text}")
error_data = json.loads(response.text)
#decoding unicode response-string
error_detail = error_data.get("errors", [{}])[0].get("detail", "")
decoded_error = error_detail.encode('utf-8').decode('utf-8')
console.print(f"[bold red]ERROR {response.status_code} deleting disk {vdisk_uuid} :cross_mark:")
console.print(f"[bold red]{decoded_error}")
return False
def get_disk_info(domain_all_content):
console = Console()
# check for "vdisks" field in recieved json response
if 'vdisks' not in domain_all_content:
print("No 'vdisks' field in recieved data")
return
# get vdisk list
disks = domain_all_content['vdisks']
# check for disks
if not disks:
print("No 'disks' field in recieved data. \nProbably VM does not have any attached disks?")
console.print("[bold yellow]No 'disks' field in recieved data. \nProbably VM does not have any attached disks?")
return
disk_info_renderables = []
# Print info for each disk
for disk in disks:
# check for requiered fileds
# check for required fields
if 'id' in disk and 'verbose_name' in disk and 'size' in disk:
print(f"Name: {disk['verbose_name']}")
print(f"UUID: {disk['id']}")
print(f"Size: {disk['size']} GB")
print("-" * 51)
output_string = (
f"[bold]Name:[/] {disk['verbose_name']}\n"
f"[bold]UUID:[/] [italic]{disk['id']}[/italic]\n"
f"[bold]Size:[/] {disk['size']} GB")
disk_info_renderables.append(Panel(output_string, expand=False, border_style="magenta"))
else:
print("ERROR: failed to retrieve vdisk data.")
console.print(Columns(disk_info_renderables))
def get_cdrom_uuid(domain_all_content):
if 'cdroms' in domain_all_content:
cdrom_ids = [item['id'] for item in domain_all_content['cdroms']]
#print("CD-ROM UUIDs:")
for cdrom_id in cdrom_ids:
#print(cdrom_id)
return (cdrom_id)
else:
console.print("[bold yellow]No 'cdroms' field in recieved data. \nProbably VM does not have any CD-ROMs?")
def get_iso_name(base_url, api_key, iso_uuid):
url = f"http://{base_url}//api/iso/{iso_uuid}/"
response = requests.get(url, headers={'Authorization': api_key})
if response.status_code == 200:
iso_name = response.json()
return (f"{iso_name['filename']}")
def vm_info(base_url , api_key , vm_uuids):
domain_info = get_domain_info(base_url , api_key , vm_uuids)
domain_all_content = get_domain_all_content(base_url , api_key , vm_uuids)
def get_vm_name(base_url, api_key, vm_uuids):
console = Console()
url = f"http://{base_url}//api/domains/{vm_uuids}/"
response = requests.get(url, headers={'Authorization': api_key})
if response.status_code == 200:
vm_name = response.json()
return (f"{vm_name['verbose_name']}")
def get_vm_node_name(domain_all_content):
"""Return the node verbose_name from domain_all_content if present, else None.
Expected domain_all_content format:
"node": {"id": "<uuid>", "verbose_name": "string"}
"""
try:
if isinstance(domain_all_content, dict):
node = domain_all_content.get('node')
if isinstance(node, dict):
return node.get('verbose_name')
except Exception:
# Defensive: any unexpected structure returns None
pass
return None
def vm_info(base_url, api_key, vm_uuids):
domain_info = get_domain_info(base_url, api_key, vm_uuids)
domain_all_content = get_domain_all_content(base_url, api_key, vm_uuids)
if domain_info:
print("\n" , "=" * 14 , "Virtual Machine Info" , "=" * 15)
print(f"\t VM: {domain_info['verbose_name']}")
print(f"\t Power State: {power_state[domain_info['user_power_state']]}") #translating status code to "pretty name"
print(f"\t vDisks: {domain_info['vdisks_count']}")
print("-" * 19 , "vDisks Info" , "-" * 19)
console = Console()
# Get the node verbose name from domain_all_content (if present)
vm_node = get_vm_node_name(domain_all_content)
# fallback display value
vm_node = vm_node if vm_node else "Unknown"
vm_info_lines = (
f"[bold]Power State:[/] [bold red]{power_state[domain_info['user_power_state']]}[/bold red]"
f" \n[bold]Node:[/] [bold bright_cyan]{vm_node}[/]"
f" \n[bold]vDisks:[/] {domain_info.get('vdisks_count', 'N/A')}"
)
vm_info_renderable = Panel(vm_info_lines, title=f"[bold magenta]{domain_info['verbose_name']}" , expand=False , border_style="yellow")
vm_info_renderable=Align.center(vm_info_renderable, vertical="middle")
print("\n")
console.rule(style="yellow")
console.print(vm_info_renderable)
console.rule(title = "[bold yellow]vDisks Info" , style="grey53" , align="center")
get_disk_info(domain_all_content)
#console.rule(title = "[bold yellow]CD-ROM UUIDs" , style="grey53" , align="center")
#print(get_cdrom_uuid(domain_all_content))
console.rule(style="yellow")
def vm_info_short(base_url, api_key):
url = f"http://{base_url}/api/domains/"
response = requests.get(url, headers={'Authorization': api_key})
if response.status_code == 200:
vm_info_short = response.json()
results_vm_info_short = vm_info_short['results']
tag = vm_info_short['results'][0]['tags'][0]
print(tag)
#print(results_vm_info_short)
os.system('cls' if os.name=='nt' else 'clear')
console.print(Align.center(Panel(f"[bold magenta]Short VM overview | Total: {vm_info_short['count']}", expand=True , border_style="yellow") , vertical="middle"))
console.rule(style="grey53")
output_renderables = []
for x in results_vm_info_short:
output_string = f"VM: [bold]{x['verbose_name']}" + f"\nUUID: [italic]{x['id']}"
output_renderable = Panel(output_string, expand=False, border_style="magenta")
output_renderables.append(output_renderable) #adds current renderable
console.print(Columns(output_renderables)) #print renderables by columns
else:
print(f"Failed to retrieve data {response.status_code}")
console.rule(style="grey53")
Prompt.ask("[green_yellow bold]ENTER - return to Main Menu.... :right_arrow_curving_down:")
os.system('cls' if os.name=='nt' else 'clear')
def create_and_attach_disk(base_url , api_key , vm_id, data_pool_uuid, vdisk_size, preallocation):
def create_and_attach_disk(base_url , api_key , vm_id, data_pool_uuid, vdisk_size, disk_interface, preallocation):
domain_name=get_domain_info(base_url , api_key , vm_id)
disk_name=domain_name["verbose_name"]+"_"+secrets.token_hex(5) #generates unique hex id. this method can generate ~million unique ids
disk_name=domain_name["verbose_name"] + "_" + secrets.token_hex(5) #generates unique hex id. this method can generate ~million unique ids
url = f"http://{base_url}/api/domains/{vm_id}/create-attach-vdisk/"
headers={
"Authorization" : api_key,
"Content-Type" : "application/json",
"Authorization" : api_key,
"Content-Type" : "application/json",
}
payload= {
"verbose_name": disk_name,
"preallocation": preallocation,
"size": vdisk_size,
"datapool": data_pool_uuid,
"target_bus": "virtio",
}
response = requests.post(url , headers=headers, json=payload)
"verbose_name": disk_name,
"preallocation": preallocation,
"size": vdisk_size,
"datapool": data_pool_uuid,
"target_bus": disk_interface, #"virtio"
}
with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}")) as progress:
task = progress.add_task("Creating and attaching vDisk...", total=None)
response = requests.post(url , headers=headers, json=payload)
progress.remove_task(task)
if response.status_code == 200:
print(f"\nvDisk {disk_name} - {vdisk_size}GB has been created")
console.print(f"[grey53 italic]{disk_name} ({vdisk_size}GB)[/] :white_check_mark:")
return True
else:
print(f"ERROR creating vDisk :\n {response.status_code} - {response.text}")
return False
return False
#checks for power on.
def vm_check_power(base_url , api_key , vm_uuids):
domain_info = get_domain_info(base_url , api_key , vm_uuids)
if domain_info:
#3 - on; 2 - suspend; 1 - off; 0 - unknown
if domain_info['user_power_state'] == 3 or domain_info['user_power_state'] == 2 : #if ON or SUSPEND
raise Exception(f"VM - {vm_uuids} IS POWERED ON! \n Turn it off and relaunch Utility.")
if domain_info['user_power_state'] == 0:
raise Exception(f"VM - {vm_uuids} is UNAVAILABLE! \n Have fun figuring that out D:")
if domain_info['user_power_state'] == 1:
pass
def select_vm_by_tags(base_url, api_key, config_relative_path):
url = f"http://{base_url}/api/domains/"
response = requests.get(url, headers={'Authorization': api_key})
if response.status_code == 200:
verbose_name_input = console.input("[bold yellow]Specify tag: [/]")
output_renderables = []
vm_info_short = response.json()
y= vm_info_short
vm_id_list = []
for y in vm_info_short['results']:
for x in y['tags']:
if x['verbose_name'] == verbose_name_input:
vm_id_list.append(y['id'])
output_string = f"VM: [bold]{y['verbose_name']} [bold yellow]#{x['verbose_name']}[/]" + f"\nUUID: [italic]{y['id']}"
output_renderable = Panel(output_string, expand=False, border_style="magenta")
output_renderables.append(output_renderable) #adds current renderable
console.print(Columns(output_renderables)) #print renderables by columns
else:
print(f"Failed to retrieve data {response.status_code}")
console.rule(style="grey53")
if vm_id_list: # promt to write found VM UUIDs to config
write_to_config = Prompt.ask("[bold yellow]Write these VM UUIDs to config file?" , choices=["Y" , "N"], default="Y", case_sensitive=False)
if write_to_config == "y" or write_to_config == "Y":
config = configparser.ConfigParser()
config.read(config_relative_path)
# Remove old VM_List section if it exists, then add a fresh one
if config.has_section('VM_List'):
config.remove_section('VM_List')
config.add_section('VM_List')
for idx, vm_id in enumerate(vm_id_list, 1):
config.set('VM_List', f'uuid_{idx}', vm_id)
with open(config_relative_path, 'w') as configfile:
config.write(configfile)
console.print(f"[green bold]VM UUIDs have been written in config :pencil:")
Prompt.ask("[green_yellow bold]ENTER - return to Main Menu.... :right_arrow_curving_down:")
os.system('cls' if os.name=='nt' else 'clear')
return(vm_id_list)
def attach_iso(base_url, api_key, vm_id, iso_uuid):
url = f"http://{base_url}/api/domains/{vm_id}/attach-iso/"
domain_all_content = get_domain_all_content(base_url, api_key, vm_id)
cdrom_uuid=get_cdrom_uuid(domain_all_content)
headers={
"Authorization" : api_key,
"Content-Type" : "application/json",
}
payload= {
"iso": iso_uuid,
"cdrom": cdrom_uuid
}
with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}")) as progress:
task = progress.add_task("Attaching selected ISO to VM...", total=None)
response = requests.post(url , headers=headers, json=payload)
progress.remove_task(task)
if response.status_code == 200:
iso_name = get_iso_name(base_url, api_key, iso_uuid)
console.print(f"[grey53 italic]ISO {iso_name} attached[/] :white_check_mark:")
return True
else:
console.print(f"[bold yellow]ERROR {response.status_code} attaching ISO \nProbably VM does not have any CD-ROMs?")
return False
def vm_menu(base_url, api_key, vm_uuids, config_relative_path):
os.system('cls' if os.name=='nt' else 'clear')
config_menu_options="[gold bold][1] [grey53 italic]Show VM info \n (for selected VMs in config)[/grey53 italic]\n \
\n[gold bold][2] [grey53 italic]Show VMs Name / UUID[/grey53 italic]\n \
\n[gold bold][3] [grey53 italic]Select VMs by tag / UUID[/grey53 italic]\n \
\n\n[green_yellow bold]ENTER - return to Main Menu[/]"
config_menu_options=Align.center(config_menu_options, vertical="middle")
console = Console()
console.print(Panel(config_menu_options, title="[gold bold]Show VM info" , border_style="magenta" , width=150 , padding = 2))
sub_choice = console.input("[bold yellow]\n>>> [/]")
if sub_choice == "1":
os.system('cls' if os.name=='nt' else 'clear')
for x in vm_uuids:
vm_info(base_url , api_key , x)
Prompt.ask("[green_yellow bold]Press ENTER to proceed.. :right_arrow_curving_down:")
if sub_choice == "2":
os.system('cls' if os.name=='nt' else 'clear')
vm_info_short(base_url , api_key)
if sub_choice == "3":
os.system('cls' if os.name=='nt' else 'clear')
select_vm_by_tags(base_url , api_key, config_relative_path)

170
main.py
View File

@@ -1,130 +1,66 @@
import sys
import os
from splash_screen import *
from config_data_import import *
from cluster_api import *
from domain_api import *
from data_pools_api import *
from disk_edit_mode import *
from rich.panel import Panel
from rich.console import Console , Align
SVMU_ver="0.4-dev"
power_state = ["Unknown" , "Off" , "Suspend" , "On"] #3 - on; 2 - suspend; 1 - off; 0 - unknown
# Initialize console and clear screen
console = Console()
os.system('cls' if os.name=='nt' else 'clear')
#config.txt in the same directory with main.py
base_dir = os.getcwd() # Use the current directory as fallback
config_relative_path = os.path.join(base_dir, 'config.txt')
# Initialize config path and ensure it points to a valid profile
config_relative_path = get_default_config_path()
#config_relative_path = "Y:\\py\\SpaceVM_VM_Utility\\config.txt"
# Get initial configuration and show startup screen
skip_startup_splash = get_skip_startup_splash(config_relative_path)
show_startup_logo(skip_startup_splash, SVMU_ver) #shows startup splash (ASCII art)
def config_edit():
read_input=input("Create new config file? (Y / N): ")
menu_choice=str(read_input)
if menu_choice == "Y" or menu_choice == "y":
base_url = input("Type SpaceVM Controller IP: ")
api_key = input("Type your API Key: ")
data_pool_uuid = input("Type Data pool uuid you wish to use: ")
lines = [base_url, api_key, data_pool_uuid]
with open(config_relative_path, "w+") as file:
for line in lines:
file.write(line + '\n')
print("Type VM-UUID (input ENTER to stop)")
with open(config_relative_path, "a") as file: #appends new content at the end without modifying the existing data
vm_input="test"
while (vm_input != ""):
vm_input = input(">> ")
file.write(vm_input + '\n')
if os.path.exists(config_relative_path) and os.path.getsize(config_relative_path) > 0: #check if file exists and not empty
#importing API-KEY / IP / DATA POOL UUID from config
with open(config_relative_path, "r") as f: # using '\' (instead of '\\') throws syntax warning
all_lines = f.readlines()
base_url = all_lines[0].strip('\n')
api_key = "jwt " + all_lines[1].strip('\n') #actual format for api_key. That was realy obvious DACOM >:C
data_pool_uuid = all_lines[2].strip('\n')
else:
print("Config file was not found or empty.. ")
config_edit()
#importing VM-UUIDs
vm_uuids = []
with open(config_relative_path, "r") as f:
for i in range(3): # ignoring 2 first lines (IP, API-KEY)
next(f)
for line in f:
line = line.strip('\n')
if line: # checks if line is empty (EOF). ESSENTIAL, DO NOT REMOVE
vm_uuids.append(line)
#so-called INT MAIN
menu_choice=0
while(menu_choice != ""): #main menu loop
read_input=input("\nUitility Main Menu: \n1) Edit config \n2) Enter disk edit mode \n3) Show breif cluster overview \n4) Show VM info \n>>> ")
menu_choice=str(read_input)
if menu_choice == "1":
config_edit()
if menu_choice == "2":
print("\033[H\033[2J", end="") # clears cmd screen, but saves scrollback buffer
print("Select option: \n 1) Delete vDisk by UUID \n 2) Delete ALL vDisks on selected Virtual Machine \n 3) Create Disk \n 4) Prepare VMs for Courses™")
read_input=input(">> ")
menu_choice=int(read_input)
if menu_choice == 1:
read_input=input("Input vDisk uuid to delete: ")
vdisk_uuid=str(read_input)
delete_disk(base_url , api_key , vdisk_uuid)
if menu_choice == 2:
print(vm_uuids)
select_uuids=int(input("Select VM to delete disks from. \n Type VM uuid index number (from list above) to select: ")) - 1
print(f"actual selected uuid = {select_uuids}")
print(vm_uuids[select_uuids])
domain_all_content = get_domain_all_content(base_url , api_key , vm_uuids[select_uuids])
disk_uuids = get_disk_uuids(base_url , api_key , domain_all_content)
for x in disk_uuids:
delete_disk(base_url , api_key , x)
print("All attached vDisks has been deleted!")
if menu_choice == 3:
vdisk_size=str(input("Enter disk size (GB): "))
print(vm_uuids)
select_uuids=int(input("Select VM to attach new disk. \n Type VM uuid index number (from list above) to select: ")) - 1
print(f"actual selected uuid = {select_uuids}")
print(vm_uuids[select_uuids])
create_and_attach_disk(vm_uuids[select_uuids] , data_pool_uuid, vdisk_size, "falloc")
if menu_choice == 4:
print("#" * 5 , "Preparing VMs for Courses" , "#" * 5)
for x in vm_uuids: # only for removing disks
domain_uuid = x.strip('\n')
domain_info = get_domain_info(base_url , api_key , domain_uuid)
domain_all_content = get_domain_all_content(base_url , api_key , domain_uuid)
if domain_info:
print("=" * 14 , "Virtual Machine Info" , "=" * 15)
print(f"\t VM: {domain_info['verbose_name']}")
print(f"\t Power State: {power_state[domain_info['user_power_state']]}") #translating status code to "pretty name"
print(f"\t vDisks: {domain_info['vdisks_count']}")
print("-" * 19 , "vDisks Info" , "-" * 19)
get_disk_info(domain_all_content)
disk_uuids = get_disk_uuids(base_url , api_key , domain_all_content)
for y in disk_uuids:
delete_disk(base_url , api_key , y)
print("All attached vDisks has been deleted!")
for z in vm_uuids: # only for creating disks
domain_uuid = z.strip('\n')
domain_info = get_domain_info(base_url , api_key , domain_uuid)
domain_all_content = get_domain_all_content(base_url , api_key , domain_uuid)
if domain_info:
create_and_attach_disk(base_url , api_key , domain_uuid , data_pool_uuid, 10, "falloc")
create_and_attach_disk(base_url , api_key , domain_uuid , data_pool_uuid, 20, "falloc")
create_and_attach_disk(base_url , api_key , domain_uuid , data_pool_uuid, 20, "falloc")
profile_name = os.path.basename(os.path.splitext(config_relative_path)[0]) #converting full path to pretty file name
skip_startup_splash, base_url, api_key, data_pool_uuid, data_pool_name, vm_uuids, vm_names, disk1_size, disk2_size, disk3_size, disk_interface, preallocation, iso_uuid, iso_name = config_import(config_relative_path) #importing API-KEY / IP / DATA POOL UUID / VM-UUIDs from config
vm_pretty_names = ', '.join(vm_names)
menu_options=f"[gold bold][1] [grey53 italic]Utiliy Configuration / Profiles\n[/grey53 italic] \
\n[gold bold][2] [grey53 italic]Enter disk edit mode[/grey53 italic]\n \
\n[gold bold][3] [grey53 italic]Show breif cluster overview[/grey53 italic]\n \
\n[gold bold][4] [grey53 italic]Enter VM menu[/grey53 italic]\n \
\n[gold bold][5] [grey53 italic]Show data pools[/grey53 italic]\n \
\n\n[green_yellow bold]ENTER - exit Utility[/]\n\n \
[underline bold grey53]Current profile:[/] [bold green]{profile_name}[/]\n \
[bold grey53]Connected to Controller: [bright_yellow]{base_url}[/]\n Selected Data Pool: [bright_yellow]{data_pool_name}[/]\n Selected VMs:\n [bright_yellow]{vm_pretty_names}[/]\n Auto-mount ISO: [bright_cyan]{iso_name}[/]"
menu_options=Align.center(menu_options, vertical="middle")
menu_subtitle = "[blue bold][link=https://github.com/OVERLORD7F/SVMU]:wrench: Project_GitHub[/link] [yellow]| [magenta bold][link=https://spacevm.ru/docs/]:books: SpaceVM_Docs[/link] [yellow]| [red bold][link=https://comptek.ru]:briefcase: Comptek[/link]"
console.print(Panel(menu_options, title=f"[bold magenta]SpaceVM Utility {SVMU_ver} - Main Menu" , subtitle = menu_subtitle, subtitle_align="right" , style="yellow" , width=150 , padding = 2))
menu_choice = console.input("[bold yellow]\n>>> [/]")
if menu_choice == "1":
result = config_menu(base_url, api_key, config_relative_path)
# Check if we got a new profile path
if isinstance(result, str):
# Update config path and reload
config_relative_path = result
os.system('cls' if os.name=='nt' else 'clear')
continue
# Check if we need to reload config
elif isinstance(result, bool) and result:
# Clear screen and continue to reload config
os.system('cls' if os.name=='nt' else 'clear')
continue
if menu_choice == "2":
disk_edit_mode(base_url , api_key , data_pool_uuid , vm_uuids, disk1_size, disk2_size, disk3_size, disk_interface, preallocation, iso_uuid)
os.system('cls' if os.name=='nt' else 'clear')
if menu_choice == "3":
cluster_info(base_url , api_key)
os.system('cls' if os.name=='nt' else 'clear')
if menu_choice == "4":
print("\033[H\033[2J", end="")
for x in vm_uuids:
vm_info(base_url , api_key , x)
print("Exiting Utility..")
sys.exit()
vm_menu(base_url , api_key, vm_uuids, config_relative_path)
os.system('cls' if os.name=='nt' else 'clear')
if menu_choice == "5":
show_data_pools(base_url , api_key)
os.system('cls' if os.name=='nt' else 'clear')
console.print("[red bold]Exiting Utility ")

76
splash-screens.txt Normal file
View File

@@ -0,0 +1,76 @@
================================================================================================================================================================
.dMMMb dMMMMb .aMMMb .aMMMb dMMMMMP dMP dMP dMMMMMMMMb dMP dMP dMMMMMMP dMP dMP dMP dMMMMMMP dMP dMP
dMP" VP dMP.dMP dMP"dMP dMP"VMP dMP dMP dMP dMP"dMP"dMP dMP dMP dMP amr dMP amr dMP dMP.dMP
VMMMb dMMMMP" dMMMMMP dMP dMMMP dMP dMP dMP dMP dMP dMP dMP dMP dMP dMP dMP dMP VMMMMP
dP .dMP dMP dMP dMP dMP.aMP dMP YMvAP" dMP dMP dMP dMP.aMP dMP dMP dMP dMP dMP dA .dMP
VMMMP" dMP dMP dMP VMMMP" dMMMMMP VP" dMP dMP dMP VMMMP" dMP dMP dMMMMMP dMP dMP VMMMP"
================================================================================================================================================================
.d8888b. 888 888 888b d888 888 888 888 d8b 888 d8b 888
d88P Y88b 888 888 8888b d8888 888 888 888 Y8P 888 Y8P 888
Y88b. 888 888 88888b.d88888 888 888 888 888 888
"Y888b. 88888b. 8888b. .d8888b .d88b. Y88b d88P 888Y88888P888 888 888 888888 888 888 888 888888 888 888
"Y88b. 888 "88b "88b d88P" d8P Y8b Y88b d88P 888 Y888P 888 888 888 888 888 888 888 888 888 888
"888 888 888 .d888888 888 88888888 Y88o88P 888 Y8P 888 888 888 888 888 888 888 888 888 888
Y88b d88P 888 d88P 888 888 Y88b. Y8b. Y888P 888 " 888 Y88b. .d88P Y88b. 888 888 888 Y88b. Y88b 888
"Y8888P" 88888P" "Y888888 "Y8888P "Y8888 Y8P 888 888 "Y88888P" "Y888 888 888 888 "Y888 "Y88888
888 888
888 Y8b d88P
888 "Y88P"
================================================================================================================================================================
oooooooo8 ooooo oooo oooo oooo ooooo oooo o8 o88 o888 o88 o8
888 ooooooooo ooooooo ooooooo ooooooooo8 888 88 8888o 888 888 88 o888oo oooo 888 oooo o888oo oooo oooo
888oooooo 888 888 ooooo888 888 888 888oooooo8 888 88 88 888o8 88 888 88 888 888 888 888 888 888 888
888 888 888 888 888 888 888 88888 88 888 88 888 88 888 888 888 888 888 888 888
o88oooo888 888ooo88 88ooo88 8o 88ooo888 88oooo888 888 o88o 8 o88o 888oo88 888o o888o o888o o888o 888o 8888
o888 o8o888
================================================================================================================================================================
______ __ __ __ __ __ __ __ __ __ __ __
/ \ | \ | \| \ / \ | \ | \ | \ | \| \| \ | \
| $$$$$$\ ______ ______ _______ ______ | $$ | $$| $$\ / $$ | $$ | $$ _| $$_ \$$| $$ \$$ _| $$_ __ __
| $$___\$$ / \ | \ / \ / \| $$ | $$| $$$\ / $$$ | $$ | $$| $$ \ | \| $$| \| $$ \ | \ | \
\$$ \ | $$$$$$\ \$$$$$$\| $$$$$$$| $$$$$$\\$$\ / $$| $$$$\ $$$$ | $$ | $$ \$$$$$$ | $$| $$| $$ \$$$$$$ | $$ | $$
_\$$$$$$\| $$ | $$ / $$| $$ | $$ $$ \$$\ $$ | $$\$$ $$ $$ | $$ | $$ | $$ __ | $$| $$| $$ | $$ __ | $$ | $$
| \__| $$| $$__/ $$| $$$$$$$| $$_____ | $$$$$$$$ \$$ $$ | $$ \$$$| $$ | $$__/ $$ | $$| \| $$| $$| $$ | $$| \| $$__/ $$
\$$ $$| $$ $$ \$$ $$ \$$ \ \$$ \ \$$$ | $$ \$ | $$ \$$ $$ \$$ $$| $$| $$| $$ \$$ $$ \$$ $$
\$$$$$$ | $$$$$$$ \$$$$$$$ \$$$$$$$ \$$$$$$$ \$ \$$ \$$ \$$$$$$ \$$$$ \$$ \$$ \$$ \$$$$ _\$$$$$$$
| $$ | \__| $$
| $$ \$$ $$
\$$ \$$$$$$
================================================================================================================================================================
______ ______ ______ ______ ______ __ __ __ __ __ __ ______ __ __ __ ______ __ __
/\ ___\ /\ == \ /\ __ \ /\ ___\ /\ ___\ /\ \ / / /\ "-./ \ /\ \/\ \ /\__ _\ /\ \ /\ \ /\ \ /\__ _\ /\ \_\ \
\ \___ \ \ \ _-/ \ \ __ \ \ \ \____ \ \ __\ \ \ \'/ \ \ \-./\ \ \ \ \_\ \ \/_/\ \/ \ \ \ \ \ \____ \ \ \ \/_/\ \/ \ \____ \
\/\_____\ \ \_\ \ \_\ \_\ \ \_____\ \ \_____\ \ \__| \ \_\ \ \_\ \ \_____\ \ \_\ \ \_\ \ \_____\ \ \_\ \ \_\ \/\_____\
\/_____/ \/_/ \/_/\/_/ \/_____/ \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/ \/_/ \/_____/
================================================================================================================================================================
███████╗██████╗ █████╗ ██████╗███████╗██╗ ██╗███╗ ███╗ ██╗ ██╗████████╗██╗██╗ ██╗████████╗██╗ ██╗
██╔════╝██╔══██╗██╔══██╗██╔════╝██╔════╝██║ ██║████╗ ████║ ██║ ██║╚══██╔══╝██║██║ ██║╚══██╔══╝╚██╗ ██╔╝
███████╗██████╔╝███████║██║ █████╗ ██║ ██║██╔████╔██║ ██║ ██║ ██║ ██║██║ ██║ ██║ ╚████╔╝
╚════██║██╔═══╝ ██╔══██║██║ ██╔══╝ ╚██╗ ██╔╝██║╚██╔╝██║ ██║ ██║ ██║ ██║██║ ██║ ██║ ╚██╔╝
███████║██║ ██║ ██║╚██████╗███████╗ ╚████╔╝ ██║ ╚═╝ ██║ ╚██████╔╝ ██║ ██║███████╗██║ ██║ ██║
╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝╚══════╝ ╚═══╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝
================================================================================================================================================================
#### ##### # # # ##### #### # # # # ##### # # ##### # # ##### ####
# # # # # # # # # # ## ## # # # # ## # # # ## # # #
# # # ##### #### #### #### # # # # # # # # # # # # # # ####
# # # # # # # # # # # # # ## # # # ## # # # #
#### # # # # # ##### #### # # # # # # # # # # # # #
================================================================================================================================================================
____
.--.--. ,' , `. ___ ,--, ___
/ / '. ,-.----. ,---. ,-+-,.' _ | ,--, ,--.'|_ ,--, ,--.'| ,--, ,--.'|_
| : /`. / \ / \ /__./| ,-+-. ; , || ,'_ /| | | :,' ,--.'| | | : ,--.'| | | :,'
; | |--` | : | ,---.; ; | ,--.'|' | ;| .--. | | : : : ' : | |, : : ' | |, : : ' :
| : ;_ | | .\ : ,--.--. ,---. ,---./___/ \ | || | ,', | ': ,'_ /| : . |.;__,' / `--'_ | ' | `--'_ .;__,' / .--,
\ \ `. . : |: | / \ / \ / \ ; \ ' || | / | | || | ' | | . .| | | ,' ,'| ' | | ,' ,'| | | | /_ ./|
`----. \| | \ :.--. .-. | / / ' / / \ \ \: |' | : | : |, | | ' | | |:__,'| : ' | | | | : ' | | :__,'| : , ' , ' :
__ \ \ || : . | \__\/: . .. ' / . ' / |; \ ' .; . | ; |--' : | | : ' ; ' : |__ | | : ' : |__ | | : ' : |__/___/ \: |
/ /`--' /: |`-' ," .--.; |' ; :__ ' ; /| \ \ '| : | | , | ; ' | | ' | | '.'|' : |__ | | '.'|' : |__ | | '.'|. \ ' |
'--'. / : : : / / ,. |' | '.'|' | / | \ ` ;| : ' |/ : | : ; ; | ; : ;| | '.'|; : ;| | '.'|; : ; \ ; :
`--'---' | | : ; : .' \ : :| : | : \ |; | |`-' ' : `--' \ | , / ; : ;| , / ; : ;| , / \ \ ;
`---'.| | , .-./\ \ / \ \ / '---" | ;/ : , .-./ ---`-' | , / ---`-' | , / ---`-' : \ \
`---` `--`---' `----' `----' '---' `--`----' ---`-' ---`-' \ ' ;
`--`

60
splash_screen.py Normal file
View File

@@ -0,0 +1,60 @@
import os
import random
import time
from rich.console import Console
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn
console = Console()
def get_skip_startup_splash(config_path):
"""Read only skip_startup_splash from config file."""
try:
with open(config_path, "r", encoding="utf-8") as f:
for line in f:
if line.strip().startswith("skip_startup_splash"):
# Example: skip_startup_splash = yes
return line.split("=")[-1].strip().lower()
except Exception:
pass
return "no" # Default if not found
def show_startup_logo(skip_startup_splash, SVMU_ver):
if skip_startup_splash == "yes":
return
splash_file = os.path.join(os.path.dirname(__file__), "splash-screens.txt")
if not os.path.exists(splash_file):
console.print("[bold red]Splash screens file not found![/bold red]")
return
with open(splash_file, "r", encoding="utf-8") as f:
content = f.read()
# Split ASCII art blocks by lines of '=' (at least 160 in a row)
blocks = [block.strip() for block in content.split("\n" + "="*160) if block.strip()]
if not blocks:
console.print("[bold red]No splash screens found![/bold red]")
return
art = random.choice(blocks)
# Animated spinner while showing splash
with Progress(
SpinnerColumn(),
TextColumn("[bold cyan]Loading SpaceVM Utility...[/bold cyan]"),
transient=True,
console=console,
) as progress:
task = progress.add_task("startup", total=None)
# Simulate loading
import time
time.sleep(1.5)
progress.remove_task(task)
# Show the ASCII art in a pretty panel
console.print(Panel.fit(art, subtitle=f"[bold magenta]{SVMU_ver}[/bold magenta]", subtitle_align="right", border_style="grey53" , width=200 , padding = 2))
time.sleep(2) #pause for 2 sec
os.system('cls' if os.name=='nt' else 'clear') #clears screen before returning
# show_startup_logo()