Guest User

Untitled

a guest
Mar 11th, 2025
89
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 21.10 KB | None | 0 0
  1. from proxmoxer import ProxmoxAPI
  2. import click
  3. import json
  4. import logging
  5. import os
  6. import requests_toolbelt
  7. import subprocess
  8. import sys
  9. import time
  10. import urllib3
  11.  
  12. logging.basicConfig(level=logging.INFO,
  13. format='%(asctime)s - %(levelname)s - %(message)s')
  14. urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
  15.  
  16.  
  17. class Proxmox:
  18. """
  19. A class to interact with the Proxmox API to manage virtual machines, storage, and volumes.
  20. """
  21.  
  22. def __init__(self, api):
  23. """
  24. Initialize the Proxmox class with an API instance.
  25.  
  26. Args:
  27. api (ProxmoxAPI): An instance of the ProxmoxAPI to interact with the Proxmox server.
  28. """
  29. self.api = api
  30.  
  31. def list_vms_on_cluster(self):
  32. """
  33. List all virtual machines (VMs) available on the cluster.
  34.  
  35. Returns:
  36. list: A list of tuples where each tuple contains the VM ID and the VM name.
  37. """
  38. vms = []
  39. nodes = self.api.nodes.get()
  40. for node in nodes:
  41. if node["status"] == "online":
  42. node_name = node["node"]
  43. vms_node = self.api.nodes(node_name).qemu.get()
  44. vms += list(map(lambda x: (x["vmid"], x["name"], node_name), vms_node))
  45. return vms
  46.  
  47. def iso_exists(self, node, storage, iso):
  48. """
  49. Check if a specific ISO file exists in a storage on a node.
  50.  
  51. Args:
  52. node (str): The name of the node.
  53. storage (str): The name of the storage.
  54. iso (str): The name of the ISO file.
  55.  
  56. Returns:
  57. bool: True if the ISO file exists, False otherwise.
  58. """
  59. volumes = self.api.nodes(node).storage(storage).content.get()
  60. return f"{storage}:iso/{iso}" in map(lambda x: x["volid"], volumes)
  61.  
  62. def upload_iso(self, node, storage, iso):
  63. iso_name = os.path.basename(iso)
  64. if self.iso_exists(node, storage, iso_name):
  65. logging.error(click.style(
  66. f'The specified iso already exists on {node}/{storage}.', fg='red'))
  67. logging.info(click.style(
  68. 'Do you wish to destroy it? Type "destroy" to do so:', fg='yellow'))
  69. verify_before_destructive_action("yes")
  70. self.delete_iso(node, storage, iso_name)
  71.  
  72. logging.info(click.style(
  73. f'Uploading iso "{iso_name}" to "{node}/{storage}"...', fg='cyan'))
  74.  
  75. # Use a context manager to open the file safely
  76. with open(iso, "rb") as iso_file:
  77. self.api.nodes(node).storage(storage).upload.create(
  78. content="iso",
  79. filename=iso_file,
  80. )
  81.  
  82. expected_size = os.path.getsize(iso)
  83. current_size = 0
  84. retries = 0
  85. max_retries = 60 # e.g., wait up to 30 seconds (60 * 0.5s)
  86. logging.info(click.style("Waiting for full upload...", fg="cyan"))
  87.  
  88. while current_size != expected_size and retries < max_retries:
  89. time.sleep(0.5)
  90. try:
  91. volume = self.api.nodes(node).storage(storage).content(f"{storage}:iso/{iso_name}").get()
  92. current_size = volume.get("size", 0)
  93. logging.info(click.style(f"Uploaded {current_size}/{expected_size} bytes...", fg="cyan"))
  94. except Exception as e:
  95. logging.error(click.style(f"Error retrieving volume info (attempt {retries+1}): {e}", fg="red"))
  96. retries += 1
  97.  
  98. if current_size != expected_size:
  99. logging.error(click.style("Upload incomplete or failed: final size does not match expected size.", fg="red"))
  100. # Optionally, you could raise an exception here or handle the error as needed.
  101. else:
  102. logging.info(click.style("Upload complete.", fg="green"))
  103.  
  104. def delete_iso(self, node, storage, iso):
  105. """
  106. Delete an ISO file to a specific storage on a node.
  107.  
  108. Args:
  109. node (str): The name of the node.
  110. storage (str): The name of the storage.
  111. iso (file): The ISO file to delete.
  112.  
  113. Raises:
  114. ValueError: ISO does not exist.
  115. """
  116. iso_name = os.path.basename(iso)
  117. logging.warn(click.style(
  118. f'Deleting iso "{iso_name}" from "{node}/{storage}"...', fg='yellow'))
  119.  
  120. if not self.iso_exists(node, storage, iso_name):
  121. logging.error(click.style(
  122. f'The iso "{iso_name}" does not exist on storage "{storage}" on node "{node}"', fg='red'))
  123. raise ValueError(
  124. f'The iso "{iso_name}" does not exist on storage "{storage}" on node "{node}"')
  125.  
  126. self.api.nodes(node).storage(storage).content(f"iso/{iso_name}").delete(
  127. node=node,
  128. )
  129. logging.info(click.style(
  130. f'Successfully deleted iso "{iso_name}" from "{node}/{storage}".', fg='green'))
  131.  
  132. def find_free_vmid(self):
  133. """
  134. Find the next available VM ID on the cluster.
  135.  
  136. Returns:
  137. int: The next available VM ID.
  138. """
  139. vms = self.list_vms_on_cluster()
  140. ids, _ = zip(*vms)
  141. return max(ids) + 1
  142.  
  143. def vmid_exists(self, vmid, vm_name):
  144. """
  145. Check if a VM ID already exists on the cluster.
  146.  
  147. Args:
  148. vmid (int): The VM ID to check.
  149. vm_name (str): The VM name to check.
  150.  
  151. Returns:
  152. bool: True if the VM ID exists, False otherwise.
  153. """
  154. vms = self.list_vms_on_cluster()
  155. if not vms:
  156. return False, None, None
  157. ids, names, nodes = zip(*vms)
  158. if vm_name in names:
  159. index = names.index(vm_name)
  160. return True, ids[index], nodes[index]
  161. return False, None, None
  162.  
  163. def vm_name_exists(self, vm_name, node):
  164. """
  165. Check if a VM with a specific name exists on a given node.
  166.  
  167. Args:
  168. vm_name (str): The VM name to check.
  169. node (str): The node where the VM should be checked.
  170.  
  171. Returns:
  172. tuple: A tuple containing a boolean indicating if the VM exists, the VM ID, and the node name if found.
  173. """
  174. vms = self.list_vms_on_cluster()
  175. if not vms:
  176. return False, None, None
  177.  
  178. ids, names, nodes = zip(*vms)
  179. if vm_name in names and node in nodes:
  180. index = names.index(vm_name)
  181. return True, ids[index], nodes[index]
  182. return False, None, None
  183.  
  184. def destroy_vm(self, vmid, node):
  185. """
  186. Destroy a VM on a specified node.
  187.  
  188. Args:
  189. vmid (str): The VM ID.
  190. node (str): The name of the node.
  191. """
  192. logging.info(click.style(
  193. f'Stopping VM "{vmid}" on node "{node}"...', fg='yellow'))
  194.  
  195. self.api.nodes(node).qemu(vmid).status.stop.post(node=node)
  196.  
  197. status = "running"
  198. for _ in range(10):
  199. status = self.api.nodes(node).qemu(
  200. vmid).status.current.get()["status"]
  201. if status == "stopped":
  202. break
  203. time.sleep(1)
  204.  
  205. if status == "running":
  206. logging.error(click.style(
  207. "The VM did not stop in time...", fg='red'))
  208. sys.exit(1)
  209.  
  210. logging.info(click.style(
  211. f'Deleting VM "{vmid}" on node "{node}"...', fg='yellow'))
  212. self.api.nodes(node).qemu(vmid).delete(node=node)
  213. logging.info(click.style(
  214. f'Successfully destroyed VM "{vmid}" on node "{node}".', fg='green'))
  215. def create_vm(self, node, config):
  216. """
  217. Create a new virtual machine (VM) with the specified configuration.
  218.  
  219. Args:
  220. node (str): The name of the node.
  221. config (dict): The configuration for the new VM.
  222.  
  223. Raises:
  224. ValueError: If the specified VM ID already exists.
  225. """
  226. api_params = config_to_api_params(config)
  227.  
  228. logging.info(click.style('Configuration:\n' +
  229. json.dumps(api_params, indent=2), fg='cyan'))
  230.  
  231. if "vmid" not in api_params.keys():
  232. logging.info(click.style(
  233. 'No VM ID set. Finding the smallest free VM ID... ', fg='cyan'))
  234. api_params["vmid"] = int(self.find_free_vmid())
  235. logging.info(click.style(
  236. f'Set VM ID to "{api_params["vmid"]}"', fg='green'))
  237.  
  238. vmid_exists, name, vm_node = self.vmid_exists(
  239. int(api_params["vmid"]))
  240. if vmid_exists:
  241. logging.warning(click.style(
  242. f'The VM "{name}" already exists on the cluster with this ID, do you want to destroy it?\n'
  243. 'WARNING: This is irreversible and will delete associated disks.\n'
  244. 'Type the VM name to confirm deletion:', fg='yellow'
  245. ))
  246. verify_before_destructive_action(name)
  247. self.destroy_vm(api_params["vmid"], vm_node)
  248.  
  249. time.sleep(1)
  250.  
  251. vm_name_exists, vmid, vm_node = self.vm_name_exists(
  252. api_params["name"], node)
  253. if vm_name_exists:
  254. logging.warning(click.style(
  255. f'The VM "{api_params["name"]}" already exists on the cluster with VM ID {vmid}, do you want to destroy it?\n'
  256. 'WARNING: This is irreversible and will delete associated disks.\n'
  257. 'Type the VM name to confirm deletion:', fg='yellow'
  258. ))
  259. verify_before_destructive_action(str(vmid))
  260. self.destroy_vm(api_params["vmid"], vm_node)
  261.  
  262. logging.info(click.style(
  263. f'Creating VM "{api_params["name"]}" with VM ID {api_params["vmid"]} on node "{node}"...', fg='green'))
  264. self.api.nodes(node).qemu.create(**api_params)
  265. logging.info(click.style(
  266. f'Successfully created VM "{api_params["name"]}" on node "{node}".', fg='green'))
  267.  
  268.  
  269. def eval_config(machine, flake):
  270. """
  271. Evaluate the NixOS configuration for a specific machine.
  272.  
  273. Args:
  274. machine (str): The name of the machine.
  275. flake (bool): A flag indicating whether to use the Nix flake system.
  276.  
  277. Returns:
  278. str: The JSON output of the evaluated NixOS configuration.
  279. """
  280. logging.info(click.style('Evaluating configuration...', fg='cyan'))
  281.  
  282. try:
  283. result = subprocess.run(
  284. ["nix", "eval", "--json"]
  285. + ([] if flake else ["-f", "default.nix"])
  286. + [(".#" if flake else"")+f"nixosConfigurations.{machine}.config.virtualisation.proxmox"],
  287. check=True,
  288. stdout=subprocess.PIPE,
  289. stderr=subprocess.PIPE,
  290. text=True
  291. )
  292. logging.info(click.style(
  293. 'Configuration evaluation completed successfully.', fg='green'))
  294. return result.stdout
  295.  
  296. except subprocess.CalledProcessError as e:
  297. logging.error(click.style('Machine evaluation failed:', fg='red'))
  298. logging.error(click.style(e.stderr, fg='red'))
  299. sys.exit(1)
  300.  
  301.  
  302. def build_iso(machine, flake):
  303. """
  304. Build the iso specified by the user
  305.  
  306. Args:
  307. machine (str): The name of the machine.
  308. flake (bool): A flag indicating whether to use the Nix flake system.
  309. """
  310.  
  311. logging.info(click.style('Building iso...', fg='cyan'))
  312.  
  313. try:
  314. subprocess.run(
  315. ["nix", "--print-build-logs", "build"]
  316. + ([] if flake else ["-f", "default.nix"])
  317. + [(".#" if flake else "")+f"nixosConfigurations.{machine}.config.virtualisation.proxmox.iso"],
  318. check=True,
  319. stdout=sys.stdout,
  320. stderr=sys.stderr,
  321. text=True
  322. )
  323. logging.info(click.style(
  324. 'Iso built successfully.', fg='green'))
  325.  
  326. except subprocess.CalledProcessError as e:
  327. logging.error(click.style('ISO building failed', fg='red'))
  328. logging.error(click.style(e.stderr, fg='red'))
  329. sys.exit(1)
  330.  
  331.  
  332. def find_iso(path):
  333. """
  334. Find the iso file path inside the output folder
  335. """
  336. iso_folder = os.path.join(path, "iso")
  337.  
  338. if not os.path.exists(iso_folder):
  339. logging.error(click.style(
  340. f"The folder '{iso_folder}' does not exist.", fg='red'))
  341. return None
  342.  
  343. files = os.listdir(iso_folder)
  344. logging.info(click.style(f"Files in '{iso_folder}': {files}", fg='cyan'))
  345.  
  346. iso_files = [file for file in files if os.path.isfile(
  347. os.path.join(iso_folder, file))]
  348.  
  349. return iso_files[0] if iso_files else None
  350.  
  351.  
  352. def verify_before_destructive_action(confirmation):
  353. """
  354. Prompt the user to confirm a potentially destructive action.
  355.  
  356. Args:
  357. confirmation (str): The word the user must type to confirm the action.
  358. """
  359. logging.warning(click.style(
  360. f"To proceed, please type the word '{confirmation}':", fg='yellow'))
  361.  
  362. while True:
  363. user_input = input(click.style(
  364. "Enter the confirmation word: ", fg='cyan')).strip()
  365. if user_input == confirmation:
  366. logging.info(click.style(
  367. "Action confirmed. Proceeding...", fg='green'))
  368. break
  369. else:
  370. logging.error(click.style(
  371. f"Incorrect input. You must type '{confirmation}' to confirm, or exit.", fg='red'))
  372.  
  373.  
  374. def parse_configuration():
  375. """
  376. Parse configuration values from three possible sources in order of priority:
  377. 1. Environment variables.
  378. 2. Local directory configuration file 'nixmoxer.conf'.
  379. 3. Configuration file located at '$XDG_CONFIG_HOME/nixmoxer.conf'.
  380.  
  381. Returns:
  382. dict: A dictionary containing the configuration key-value pairs.
  383. """
  384. xdg_config_home = os.getenv(
  385. 'XDG_CONFIG_HOME', os.path.expanduser('~/.config'))
  386. xdg_config_file_path = os.path.join(xdg_config_home, 'nixmoxer.conf')
  387. local_config_file_path = os.path.join(os.getcwd(), 'nixmoxer.conf')
  388. config = {}
  389.  
  390. if os.path.exists(xdg_config_file_path):
  391. logging.info(click.style(
  392. f"Loading configuration from {xdg_config_file_path}...", fg='cyan'))
  393. config.update(read_config_file(xdg_config_file_path))
  394.  
  395. if os.path.exists(local_config_file_path):
  396. logging.info(click.style(
  397. f"Loading configuration from {local_config_file_path}...", fg='cyan'))
  398. config.update(read_config_file(local_config_file_path))
  399.  
  400. for key, value in os.environ.items():
  401. if key.startswith("PROXMOX_"):
  402. config_key = key[len("PROXMOX_"):]
  403. config[config_key.lower()] = value
  404. logging.info(click.style(
  405. f"Environment variable '{key}' loaded as '{config_key.lower()}'", fg='cyan'))
  406.  
  407. # Validate required keys
  408. if "host" not in config.keys():
  409. logging.error(click.style(
  410. f'Missing authentication configuration for Proxmox. Please set "host" in {local_config_file_path}', fg='red'))
  411. raise KeyError('Missing "host" configuration')
  412.  
  413. if "user" not in config.keys():
  414. logging.error(click.style(
  415. f'Missing authentication configuration for Proxmox. Please set "user" in {local_config_file_path}', fg='red'))
  416. raise KeyError('Missing "user" configuration')
  417.  
  418. if "password" not in config.keys() and ("token_name" not in config.keys() or "token_value" not in config.keys()):
  419. logging.error(click.style(
  420. 'Missing authentication configuration for Proxmox. Please set either "password" or both "token_name" and "token_value"', fg='red'))
  421. raise KeyError(
  422. 'Missing "password" or "token_name" and "token_value" configuration')
  423.  
  424. logging.info(click.style("Configuration parsed successfully.", fg='green'))
  425. return config
  426.  
  427.  
  428. def read_config_file(file_path):
  429. """
  430. Read key-value pairs from a configuration file.
  431.  
  432. Args:
  433. file_path (str): The path to the configuration file.
  434.  
  435. Returns:
  436. dict: A dictionary containing key-value pairs from the file.
  437. """
  438. config = {}
  439. with open(file_path, 'r') as file:
  440. for line in file:
  441. line = line.strip()
  442. # Ignore empty lines and comments
  443. if line and not line.startswith('#') and '=' in line:
  444. key, value = line.split('=', 1)
  445. config[key.strip().lower()] = value.strip()
  446. return config
  447.  
  448.  
  449. def config_to_api_params(config):
  450. """
  451. Map configuration dictionary to Proxmox API parameters, excluding None values and empty lists.
  452. Converts booleans to 0 or 1, transforms dictionaries to key=value strings, processes lists of dictionaries,
  453. and concatenates lists of strings.
  454.  
  455. Args:
  456. config (dict): Configuration dictionary.
  457.  
  458. Returns:
  459. dict: Filtered Proxmox API parameters.
  460. """
  461. def process_value(key, value):
  462. # Convert boolean to 0 or 1
  463. if isinstance(value, bool):
  464. return 1 if value else 0
  465.  
  466. # Handle string "True" and "False" as booleans
  467. if isinstance(value, str):
  468. if value == "True":
  469. return 1
  470. elif value == "False":
  471. return 0
  472.  
  473. # Convert dictionary to "key1=value1,key2=value2,..."
  474. if isinstance(value, dict):
  475. result = {k: process_value(k, v) for k, v in value.items() if v not in [
  476. None, {}, []]}
  477. return ','.join(f"{k}={v}" for k, v in result.items()) if result else None
  478.  
  479. # Process list of strings by concatenating with a comma
  480. if isinstance(value, list):
  481. if all(isinstance(item, str) for item in value):
  482. concatenated = ','.join(value)
  483. return concatenated if concatenated else None
  484.  
  485. # Process list of dictionaries and flatten the keys
  486. if all(isinstance(item, dict) for item in value):
  487. flattened = {}
  488. for i, item in enumerate(value):
  489. result = process_value(f"{key}{i}", item)
  490. if isinstance(result, str):
  491. flattened[f"{key}{i}"] = result
  492. return flattened
  493.  
  494. # Process list of other values (e.g., numbers) by concatenating with a comma
  495. if all(isinstance(item, (int, float)) for item in value):
  496. return ','.join(map(str, value)) if value else None
  497.  
  498. # Filter out None or empty string values
  499. if value in [None, '']:
  500. return None
  501.  
  502. # Return value directly if it's not a dict, list, None, or empty string
  503. return value
  504.  
  505. api_params = {}
  506. for k, v in config.items():
  507. processed_value = process_value(k, v)
  508. if isinstance(processed_value, dict):
  509. # Merge flattened dictionaries with top level
  510. api_params.update(processed_value)
  511. elif processed_value is not None:
  512. api_params[k] = processed_value
  513.  
  514. return api_params
  515.  
  516.  
  517. def authenticate_promox():
  518. """
  519. Authenticate Proxmox using the config.
  520. """
  521. try:
  522. config = parse_configuration()
  523.  
  524. if "token_name" in config.keys() and "token_value" in config.keys():
  525. logging.info(click.style(
  526. "Authenticating with token...", fg='cyan'))
  527. api = ProxmoxAPI(
  528. config["host"],
  529. user=config["user"],
  530. token_name=config["token_name"],
  531. token_value=config["token_value"],
  532. verify_ssl=bool(int(config.get("verify_ssl", True))),
  533. timeout=30
  534. )
  535. else:
  536. logging.info(click.style(
  537. "Authenticating with password...", fg='cyan'))
  538. api = ProxmoxAPI(
  539. config["host"],
  540. user=config["user"],
  541. password=config["password"],
  542. verify_ssl=bool(int(config.get("verify_ssl", True))),
  543. timeout=30
  544. )
  545.  
  546. logging.info(click.style("Authentication successful.", fg='green'))
  547. return Proxmox(api)
  548.  
  549. except KeyError as e:
  550. logging.error(click.style(
  551. f"Authentication failed:\n {str(e)}", fg='red'))
  552. sys.exit(1)
  553.  
  554. @click.command()
  555. @click.option('--flake', is_flag=True)
  556. @click.argument('machine')
  557. def bootstrap(flake, machine):
  558. """
  559. Bootstrap the NixOS configuration for a specific machine using Nix flake or non-flake.
  560.  
  561. Args:
  562. flake (bool): A flag indicating whether to use the Nix flake system.
  563. machine (str): The name of the machine to bootstrap.
  564. """
  565. proxmox = authenticate_promox()
  566. config = eval_config(machine, flake)
  567. config = json.loads(config)
  568. node = config['node']
  569. config.pop('autoInstall', None)
  570.  
  571. build_iso(machine, flake)
  572. iso_file = find_iso(config["iso"])
  573. proxmox.upload_iso(node, "local", config["iso"]+f"/iso/{iso_file}")
  574. config["cdrom"] = f"local:iso/{iso_file}"
  575.  
  576. config.pop('iso', None)
  577. proxmox.create_vm(node, config)
  578. logging.info(click.style("Virtual machine successfully created!", fg='green'))
  579.  
  580.  
  581. def run_main():
  582. """
  583. Entry point to run the main program logic, starting with the bootstrap command.
  584. """
  585. bootstrap()
  586.  
Advertisement
Add Comment
Please, Sign In to add comment