From 4628fc266f3478b747d8f1bda3a325c12de1a60d Mon Sep 17 00:00:00 2001 From: Tellsanguis Date: Thu, 27 Nov 2025 12:51:08 +0100 Subject: [PATCH] feat(cicd): Use Proxmox API instead of SSH for LINSTOR management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version 2.0 du script de gestion LINSTOR Changements majeurs: - Remplace les commandes SSH/LINSTOR par l'API Proxmox REST - Ajoute une classe ProxmoxAPI pour gérer les appels API - Utilise les endpoints /cluster/linstor/* de l'API Proxmox - Installe les dépendances Python (requests, urllib3) dans le pipeline - Passe les credentials API via variables d'environnement/secrets - Plus sécurisé: pas besoin de clés SSH, utilise les tokens API existants - Support des certificats auto-signés (verify_ssl=False) Auteur: BENE Maël --- .forgejo/workflows/deploy.yml | 30 ++- scripts/manage_linstor_resources.py | 309 +++++++++++++++++----------- 2 files changed, 205 insertions(+), 134 deletions(-) diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 56dc3c8..45685f3 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -81,14 +81,19 @@ jobs: if ! command -v tofu &> /dev/null; then curl -fsSL https://get.opentofu.org/install-opentofu.sh | bash -s -- --install-method standalone --opentofu-version 1.10.7 fi - - name: Setup Python + - name: Setup Python and dependencies run: | - apt-get update && apt-get install -y python3 + apt-get update && apt-get install -y python3 python3-pip + pip3 install --break-system-packages requests urllib3 - name: Prepare LINSTOR resources for pve1 run: | - # Exécute le script dans le container, qui utilisera SSH pour communiquer avec LINSTOR - # Utilise l'IP au lieu du hostname car le container Docker ne peut pas résoudre les noms locaux - python3 scripts/manage_linstor_resources.py --terraform-dir terraform --remote-host 192.168.100.30 --verbose + # Exécute le script dans le container, qui utilisera l'API Proxmox pour gérer LINSTOR + python3 scripts/manage_linstor_resources.py \ + --terraform-dir terraform \ + --api-url "${{ secrets.PROXMOX_API_URL || 'https://192.168.100.10:8006/api2/json' }}" \ + --token-id "${{ secrets.PROXMOX_TOKEN_ID }}" \ + --token-secret "${{ secrets.PROXMOX_TOKEN_SECRET }}" \ + --verbose - name: Terraform Apply on pve1 run: | cd terraform/pve1 @@ -124,14 +129,19 @@ jobs: if ! command -v tofu &> /dev/null; then curl -fsSL https://get.opentofu.org/install-opentofu.sh | bash -s -- --install-method standalone --opentofu-version 1.10.7 fi - - name: Setup Python + - name: Setup Python and dependencies run: | - apt-get update && apt-get install -y python3 + apt-get update && apt-get install -y python3 python3-pip + pip3 install --break-system-packages requests urllib3 - name: Prepare LINSTOR resources for pve2 run: | - # Exécute le script dans le container, qui utilisera SSH pour communiquer avec LINSTOR - # Utilise l'IP au lieu du hostname car le container Docker ne peut pas résoudre les noms locaux - python3 scripts/manage_linstor_resources.py --terraform-dir terraform --remote-host 192.168.100.30 --verbose + # Exécute le script dans le container, qui utilisera l'API Proxmox pour gérer LINSTOR + python3 scripts/manage_linstor_resources.py \ + --terraform-dir terraform \ + --api-url "${{ secrets.PROXMOX_API_URL || 'https://192.168.100.10:8006/api2/json' }}" \ + --token-id "${{ secrets.PROXMOX_TOKEN_ID }}" \ + --token-secret "${{ secrets.PROXMOX_TOKEN_SECRET }}" \ + --verbose - name: Terraform Apply on pve2 run: | cd terraform/pve2 diff --git a/scripts/manage_linstor_resources.py b/scripts/manage_linstor_resources.py index 4d71299..d9bcb71 100644 --- a/scripts/manage_linstor_resources.py +++ b/scripts/manage_linstor_resources.py @@ -1,30 +1,35 @@ #!/usr/bin/env python3 """ -Script de Gestion des Ressources LINSTOR pour Proxmox +Script de Gestion des Ressources LINSTOR pour Proxmox via API Auteur: BENE Maël -Version: 1.0 +Version: 2.0 Date: 2025-11-27 Description: - Ce script gère automatiquement les ressources LINSTOR pour les VMs Proxmox. + Ce script gère automatiquement les ressources LINSTOR pour les VMs Proxmox + en utilisant l'API Proxmox au lieu de commandes SSH directes. Il assure que les ressources existent avec la taille correcte avant le déploiement. Fonctionnalités: - - Vérifie l'existence d'une ressource + - Vérifie l'existence d'une ressource via API Proxmox - Crée la ressource si elle n'existe pas - Redimensionne la ressource si la taille ne correspond pas (uniquement augmentation) - Opérations idempotentes (peut être exécuté plusieurs fois en toute sécurité) """ -import subprocess import json import sys import argparse import re import os +import urllib3 +import requests from pathlib import Path +# Désactive les avertissements SSL pour les certificats auto-signés +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + # Configuration des ressources par défaut # Ces valeurs peuvent être modifiées selon vos besoins @@ -47,50 +52,119 @@ RESOURCE_CONFIG = { } -class LinstorManager: - """Gestionnaire des ressources LINSTOR""" +class ProxmoxAPI: + """Client pour l'API Proxmox""" - def __init__(self, verbose=False, remote_host=None): + def __init__(self, api_url, token_id, token_secret, verify_ssl=False, verbose=False): + self.api_url = api_url.rstrip('/') + self.token_id = token_id + self.token_secret = token_secret + self.verify_ssl = verify_ssl self.verbose = verbose - self.remote_host = remote_host # Hôte sur lequel exécuter les commandes LINSTOR via SSH + self.headers = { + 'Authorization': f'PVEAPIToken={token_id}={token_secret}' + } def log(self, message): """Affiche un message de log si le mode verbose est activé""" if self.verbose: print(f"[INFO] {message}") - def run_command(self, command, remote_host=None): - """Exécute une commande shell et retourne la sortie - - Args: - command: Liste de commandes à exécuter - remote_host: Si spécifié, exécute la commande via SSH sur cet hôte - """ - if remote_host: - # Construit la commande SSH - ssh_command = ['ssh', f'root@{remote_host}'] + command - self.log(f"Exécution SSH sur {remote_host}: {' '.join(command)}") - command = ssh_command - else: - self.log(f"Exécution locale: {' '.join(command)}") + def _request(self, method, endpoint, data=None): + """Effectue une requête HTTP vers l'API Proxmox""" + url = f"{self.api_url}{endpoint}" + self.log(f"{method} {endpoint}") try: - result = subprocess.run( - command, - capture_output=True, - text=True, - check=True - ) - return result.stdout.strip() - except subprocess.CalledProcessError as e: - print(f"[ERREUR] Échec de la commande: {' '.join(command)}", file=sys.stderr) - print(f"[ERREUR] Code de sortie: {e.returncode}", file=sys.stderr) - print(f"[ERREUR] Stdout: {e.stdout}", file=sys.stderr) - print(f"[ERREUR] Stderr: {e.stderr}", file=sys.stderr) + if method == 'GET': + response = requests.get(url, headers=self.headers, verify=self.verify_ssl) + elif method == 'POST': + response = requests.post(url, headers=self.headers, data=data, verify=self.verify_ssl) + elif method == 'PUT': + response = requests.put(url, headers=self.headers, data=data, verify=self.verify_ssl) + elif method == 'DELETE': + response = requests.delete(url, headers=self.headers, verify=self.verify_ssl) + else: + raise ValueError(f"Méthode HTTP non supportée: {method}") + + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + print(f"[ERREUR] Requête API échouée: {e}", file=sys.stderr) + if hasattr(e.response, 'text'): + print(f"[ERREUR] Réponse: {e.response.text}", file=sys.stderr) return None + def linstor_resource_definitions_list(self): + """Liste toutes les définitions de ressources LINSTOR""" + result = self._request('GET', '/cluster/linstor/resource-definitions') + if result and 'data' in result: + return result['data'] + return [] + + def linstor_resource_definition_create(self, resource_name): + """Crée une définition de ressource LINSTOR""" + data = {'id': resource_name} + result = self._request('POST', '/cluster/linstor/resource-definitions', data) + return result is not None + + def linstor_volume_definitions_list(self, resource_name): + """Liste les définitions de volumes pour une ressource""" + result = self._request('GET', f'/cluster/linstor/resource-definitions/{resource_name}/volumes') + if result and 'data' in result: + return result['data'] + return [] + + def linstor_volume_definition_create(self, resource_name, size_kib): + """Crée une définition de volume LINSTOR""" + data = { + 'volume-number': '0', + 'size-kib': str(size_kib) + } + result = self._request('POST', f'/cluster/linstor/resource-definitions/{resource_name}/volumes', data) + return result is not None + + def linstor_volume_definition_resize(self, resource_name, volume_number, size_kib): + """Redimensionne un volume LINSTOR""" + data = {'size-kib': str(size_kib)} + result = self._request('PUT', f'/cluster/linstor/resource-definitions/{resource_name}/volumes/{volume_number}', data) + return result is not None + + def linstor_resources_list(self, resource_name=None): + """Liste les ressources LINSTOR""" + if resource_name: + result = self._request('GET', f'/cluster/linstor/resources/{resource_name}') + else: + result = self._request('GET', '/cluster/linstor/resources') + + if result and 'data' in result: + return result['data'] + return [] + + def linstor_resource_create(self, node, resource_name, storage_pool): + """Crée une ressource LINSTOR sur un nœud""" + data = { + 'node': node, + 'storage-pool': storage_pool + } + result = self._request('POST', f'/cluster/linstor/resources/{resource_name}', data) + return result is not None + + +class LinstorManager: + """Gestionnaire des ressources LINSTOR via API Proxmox""" + + def __init__(self, proxmox_api, verbose=False): + self.api = proxmox_api + self.verbose = verbose + + def log(self, message): + """Affiche un message de log si le mode verbose est activé""" + if self.verbose: + print(f"[INFO] {message}") + def parse_size(self, size_str): - """Convertit une chaîne de taille (ex: '100G', '1024M') en octets""" + """Convertit une chaîne de taille (ex: '100G', '1024M') en KiB""" size_str = size_str.strip().upper() # Correspond au nombre et à l'unité @@ -101,18 +175,20 @@ class LinstorManager: number, unit = match.groups() number = float(number) + # Convertit en KiB multipliers = { - '': 1, - 'K': 1024, - 'M': 1024**2, - 'G': 1024**3, - 'T': 1024**4, + '': 1 / 1024, # Bytes vers KiB + 'K': 1, # KiB + 'M': 1024, # MiB vers KiB + 'G': 1024**2, # GiB vers KiB + 'T': 1024**3, # TiB vers KiB } return int(number * multipliers.get(unit, 1)) - def format_size(self, bytes_value): - """Formate les octets en taille lisible""" + def format_size(self, kib_value): + """Formate les KiB en taille lisible""" + bytes_value = kib_value * 1024 for unit in ['', 'K', 'M', 'G', 'T']: if bytes_value < 1024.0: return f"{bytes_value:.0f}{unit}iB" if unit else f"{bytes_value:.0f}B" @@ -121,103 +197,66 @@ class LinstorManager: def resource_exists(self, resource_name): """Vérifie si une définition de ressource LINSTOR existe""" - output = self.run_command(['linstor', 'resource-definition', 'list', '--machine-readable'], self.remote_host) - if output is None: - return False - - try: - data = json.loads(output) - for item in data: - if isinstance(item, dict) and item.get('name') == resource_name: - self.log(f"La définition de ressource '{resource_name}' existe") - return True - except json.JSONDecodeError: - self.log("Échec de l'analyse de la sortie resource-definition list") - + resources = self.api.linstor_resource_definitions_list() + for res in resources: + if res.get('name') == resource_name: + self.log(f"La définition de ressource '{resource_name}' existe") + return True return False def get_resource_size(self, resource_name): - """Récupère la taille actuelle d'un volume de ressource (en octets)""" - output = self.run_command(['linstor', 'volume-definition', 'list', '--machine-readable'], self.remote_host) - if output is None: - return None - - try: - data = json.loads(output) - for item in data: - if isinstance(item, dict): - if item.get('resource_name') == resource_name and item.get('volume_number') == 0: - # La taille est en KiB dans LINSTOR - size_kib = item.get('size_kib', 0) - size_bytes = size_kib * 1024 - self.log(f"Taille actuelle de '{resource_name}': {self.format_size(size_bytes)}") - return size_bytes - except json.JSONDecodeError: - self.log("Échec de l'analyse de la sortie volume-definition list") - + """Récupère la taille actuelle d'un volume de ressource (en KiB)""" + volumes = self.api.linstor_volume_definitions_list(resource_name) + for vol in volumes: + if vol.get('volume_number') == 0 or vol.get('volume-number') == 0: + size_kib = vol.get('size_kib') or vol.get('size-kib', 0) + self.log(f"Taille actuelle de '{resource_name}': {self.format_size(size_kib)}") + return size_kib return None def get_resource_nodes(self, resource_name): """Récupère la liste des nœuds où la ressource est déployée""" - output = self.run_command(['linstor', 'resource', 'list', '--machine-readable'], self.remote_host) - if output is None: - return [] - + resources = self.api.linstor_resources_list(resource_name) nodes = set() - try: - data = json.loads(output) - for item in data: - if isinstance(item, dict) and item.get('name') == resource_name: - node = item.get('node_name') - if node: - nodes.add(node) - except json.JSONDecodeError: - self.log("Échec de l'analyse de la sortie resource list") - + for res in resources: + node = res.get('node_name') or res.get('node-name') + if node: + nodes.add(node) return list(nodes) def create_resource_definition(self, resource_name): """Crée une définition de ressource LINSTOR""" self.log(f"Création de la définition de ressource '{resource_name}'") - output = self.run_command(['linstor', 'resource-definition', 'create', resource_name], self.remote_host) - if output is None: - return False - print(f"✓ Définition de ressource '{resource_name}' créée") - return True + if self.api.linstor_resource_definition_create(resource_name): + print(f"✓ Définition de ressource '{resource_name}' créée") + return True + return False def create_volume_definition(self, resource_name, size): """Crée une définition de volume LINSTOR""" + size_kib = self.parse_size(size) self.log(f"Création de la définition de volume pour '{resource_name}' avec taille {size}") - output = self.run_command(['linstor', 'volume-definition', 'create', resource_name, size], self.remote_host) - if output is None: - return False - print(f"✓ Définition de volume créée pour '{resource_name}' avec taille {size}") - return True + if self.api.linstor_volume_definition_create(resource_name, size_kib): + print(f"✓ Définition de volume créée pour '{resource_name}' avec taille {size}") + return True + return False def create_resource(self, node, resource_name, storage_pool): """Crée une ressource LINSTOR sur un nœud spécifique""" self.log(f"Création de la ressource '{resource_name}' sur le nœud '{node}' avec le pool de stockage '{storage_pool}'") - output = self.run_command([ - 'linstor', 'resource', 'create', - node, resource_name, - '--storage-pool', storage_pool - ], self.remote_host) - if output is None: - return False - print(f"✓ Ressource '{resource_name}' créée sur le nœud '{node}'") - return True + if self.api.linstor_resource_create(node, resource_name, storage_pool): + print(f"✓ Ressource '{resource_name}' créée sur le nœud '{node}'") + return True + return False def resize_volume(self, resource_name, new_size): """Redimensionne un volume LINSTOR (uniquement augmentation)""" + new_size_kib = self.parse_size(new_size) self.log(f"Redimensionnement du volume '{resource_name}' à {new_size}") - output = self.run_command([ - 'linstor', 'volume-definition', 'set-size', - resource_name, '0', new_size - ], self.remote_host) - if output is None: - return False - print(f"✓ Volume '{resource_name}' redimensionné à {new_size}") - return True + if self.api.linstor_volume_definition_resize(resource_name, 0, new_size_kib): + print(f"✓ Volume '{resource_name}' redimensionné à {new_size}") + return True + return False def ensure_resource(self, resource_name, node, size, storage_pool): """ @@ -256,19 +295,19 @@ class LinstorManager: # La ressource existe - vérifier la taille print(f"La ressource '{resource_name}' existe déjà. Vérification de la taille...") current_size = self.get_resource_size(resource_name) - desired_size_bytes = self.parse_size(size) + desired_size_kib = self.parse_size(size) if current_size is None: print(f"[ATTENTION] Impossible de déterminer la taille actuelle de '{resource_name}'") return False - if current_size < desired_size_bytes: + if current_size < desired_size_kib: print(f"Taille actuelle ({self.format_size(current_size)}) inférieure à la taille désirée ({size})") print(f"Redimensionnement de la ressource '{resource_name}' à {size}...") if not self.resize_volume(resource_name, size): return False print(f"✓ Ressource '{resource_name}' redimensionnée avec succès") - elif current_size > desired_size_bytes: + elif current_size > desired_size_kib: print(f"[ATTENTION] Taille actuelle ({self.format_size(current_size)}) supérieure à la taille désirée ({size})") print(f"[ATTENTION] LINSTOR ne supporte pas la réduction de volumes. Conservation de la taille actuelle.") else: @@ -345,9 +384,9 @@ def parse_terraform_config(terraform_dir): if 'etcd' in storage_var.lower(): storage_pool = 'local-lvm' else: - storage_pool = 'linstor_storage' + storage_pool = 'pve-storage' else: - storage_pool = 'linstor_storage' + storage_pool = 'pve-storage' resource_name = f"vm-{vmid}-disk-0" config[resource_name] = { @@ -365,7 +404,7 @@ def parse_terraform_config(terraform_dir): def main(): parser = argparse.ArgumentParser( - description='Gestion des ressources LINSTOR pour les VMs Proxmox', + description='Gestion des ressources LINSTOR pour les VMs Proxmox via API', formatter_class=argparse.RawDescriptionHelpFormatter ) @@ -375,13 +414,35 @@ def main(): parser.add_argument('--storage-pool', help='Nom du pool de stockage (ex: pve-storage)') parser.add_argument('--terraform-dir', help='Chemin vers le répertoire Terraform pour lecture automatique de la config') parser.add_argument('--all', action='store_true', help='Traiter toutes les ressources configurées') - parser.add_argument('--remote-host', help='Hôte sur lequel exécuter les commandes LINSTOR via SSH (ex: thinkpad)') + + # Paramètres API Proxmox + parser.add_argument('--api-url', help='URL de l\'API Proxmox (ex: https://192.168.100.10:8006/api2/json)', + default=os.environ.get('PROXMOX_API_URL')) + parser.add_argument('--token-id', help='ID du token API Proxmox', + default=os.environ.get('PROXMOX_TOKEN_ID')) + parser.add_argument('--token-secret', help='Secret du token API Proxmox', + default=os.environ.get('PROXMOX_TOKEN_SECRET')) + parser.add_argument('--verbose', '-v', action='store_true', help='Active la sortie détaillée') args = parser.parse_args() - # Utilise --remote-host si spécifié, sinon utilise le premier noeud trouvé dans la config - manager = LinstorManager(verbose=args.verbose, remote_host=args.remote_host) + # Vérifie que les credentials API sont fournis + if not args.api_url or not args.token_id or not args.token_secret: + print("[ERREUR] Les credentials API Proxmox sont requis (--api-url, --token-id, --token-secret)", file=sys.stderr) + print("[INFO] Vous pouvez aussi les définir via les variables d'environnement PROXMOX_API_URL, PROXMOX_TOKEN_ID, PROXMOX_TOKEN_SECRET", file=sys.stderr) + sys.exit(1) + + # Initialise le client API + api = ProxmoxAPI( + api_url=args.api_url, + token_id=args.token_id, + token_secret=args.token_secret, + verify_ssl=False, + verbose=args.verbose + ) + + manager = LinstorManager(proxmox_api=api, verbose=args.verbose) # Mode 1: Lecture automatique depuis Terraform if args.terraform_dir: