feat(cicd): Use Proxmox API instead of SSH for LINSTOR management
Some checks failed
CD - Deploy Infrastructure / Terraform Validation (push) Successful in 16s
CD - Deploy Infrastructure / Deploy on pve1 (push) Failing after 16s
CD - Deploy Infrastructure / Deploy on pve2 (push) Failing after 14s
CD - Deploy Infrastructure / Deploy on pve3 (push) Successful in 1m56s
CD - Deploy Infrastructure / Validate K3s Cluster (push) Has been skipped
CD - Deploy Infrastructure / Deployment Notification (push) Failing after 1s

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
This commit is contained in:
Tellsanguis 2025-11-27 12:51:08 +01:00
parent 287410732f
commit 4628fc266f
2 changed files with 205 additions and 134 deletions

View file

@ -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

View file

@ -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: