mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 11:04:43 +02:00
feat: initial commit — Ansible roles storage-01/gpu-01 opérationnels
This commit is contained in:
commit
b3fce8af5d
70 changed files with 2688 additions and 0 deletions
369
ansible/roles/lm_studio/TROUBLESHOOTING.md
Normal file
369
ansible/roles/lm_studio/TROUBLESHOOTING.md
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
# LM Studio sur AlmaLinux 9 — Déblocages et solutions
|
||||
|
||||
## Contexte
|
||||
|
||||
- **Machine** : gpu-01, AlmaLinux 9.7, GCC 11, libstdc++ → max `GLIBCXX_3.4.29`
|
||||
- **Problème initial** : LM Studio refuse de démarrer avec `GLIBCXX_3.4.30 not found`
|
||||
- **Cause racine** : `watcher.node` (module Node natif livré avec LM Studio) a été compilé avec GCC 12 et déclare une dépendance ELF vers `GLIBCXX_3.4.30`. AlmaLinux 9 ne fournit que GCC 11 → `GLIBCXX_3.4.29` maximum.
|
||||
|
||||
---
|
||||
|
||||
## Blocage 1 — LD_PRELOAD ne peut pas satisfaire DT_VERNEED
|
||||
|
||||
### Ce qui a été tenté
|
||||
|
||||
Construire un shim `.so` qui exporte `_ZNSt18condition_variable4waitERSt11unique_lockISt5mutexE` sous le tag de version `GLIBCXX_3.4.30`, puis l'injecter via `LD_PRELOAD`.
|
||||
|
||||
```c
|
||||
// glibcxx_compat.c
|
||||
__asm__(".symver _ZNSt18condition_variable4waitERSt11unique_lockISt5mutexE_compat,"
|
||||
"_ZNSt18condition_variable4waitERSt11unique_lockISt5mutexE@GLIBCXX_3.4.30");
|
||||
```
|
||||
|
||||
```
|
||||
gcc -shared -fPIC -Wl,--version-script=glibcxx_compat.map \
|
||||
-o libglibcxx_compat.so glibcxx_compat.c /usr/lib64/libstdc++.so.6
|
||||
```
|
||||
|
||||
### Pourquoi ça échoue
|
||||
|
||||
Dans ELF, `DT_VERNEED` lie une exigence de version à une bibliothèque **nominalement identifiée**. La section `.gnu.version_r` de `watcher.node` dit :
|
||||
|
||||
> « J'ai besoin de `GLIBCXX_3.4.30` fourni par `libstdc++.so.6`. »
|
||||
|
||||
Le linker dynamique (`ld.so`) vérifie cette version **directement dans `libstdc++.so.6`**, pas dans les bibliothèques chargées via `LD_PRELOAD`. Un shim préchargé sous un autre nom de fichier ne peut pas satisfaire cette exigence — même s'il exporte le bon symbole sous le bon nom de version.
|
||||
|
||||
### Erreur observée
|
||||
|
||||
Malgré `LD_PRELOAD`, le daemon plante toujours avec :
|
||||
```
|
||||
/lib64/libstdc++.so.6: version GLIBCXX_3.4.30 not found
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Blocage 2 — Patch texte simple ne suffit pas (hash ELF non mis à jour)
|
||||
|
||||
### Ce qui a été tenté
|
||||
|
||||
Remplacer la chaîne `GLIBCXX_3.4.30` par `GLIBCXX_3.4.29` directement dans le binaire avec `sed` ou un patch Python simple.
|
||||
|
||||
### Pourquoi ça échoue
|
||||
|
||||
La section `.gnu.version_r` contient des structures `Elf64_Vernaux` :
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
Elf64_Word vna_hash; // hash ELF du nom de version
|
||||
Elf64_Half vna_flags;
|
||||
Elf64_Half vna_other;
|
||||
Elf64_Word vna_name; // offset dans la string table
|
||||
Elf64_Word vna_next;
|
||||
} Elf64_Vernaux;
|
||||
```
|
||||
|
||||
Chaque entrée contient **deux références** au nom de version :
|
||||
1. `vna_name` → offset vers la chaîne dans `.dynstr`
|
||||
2. `vna_hash` → hash ELF de cette chaîne (précalculé)
|
||||
|
||||
Un patch texte change la chaîne mais laisse `vna_hash` intact. Le linker dynamique vérifie le hash **en premier** — il ne trouve pas `hash(GLIBCXX_3.4.29)` dans `libstdc++.so.6` et rejette le module.
|
||||
|
||||
### Erreur observée
|
||||
|
||||
```
|
||||
/lib64/libstdc++.so.6: version GLIBCXX_3.4.29 not found
|
||||
```
|
||||
(nouveau message — la chaîne a changé mais le hash est toujours celui de `GLIBCXX_3.4.30`)
|
||||
|
||||
---
|
||||
|
||||
## Blocage 3 — Mauvais offset dans le parseur ELF
|
||||
|
||||
### Ce qui a été tenté
|
||||
|
||||
Écrire `patch-glibcxx-compat.py` pour parser `.gnu.version_r` et patcher hash + chaîne.
|
||||
|
||||
### Erreur de décodage
|
||||
|
||||
Structure `Elf64_Verneed` :
|
||||
```
|
||||
offset +0 : vn_version (2 bytes)
|
||||
offset +2 : vn_cnt (2 bytes)
|
||||
offset +4 : vn_file (4 bytes) ← oublié !
|
||||
offset +8 : vn_aux (4 bytes)
|
||||
offset +12 : vn_next (4 bytes)
|
||||
```
|
||||
|
||||
Le premier jet lisait `vn_aux` à `+4` (en sautant `vn_file`) — le parseur pointait vers de mauvaises entrées, ne trouvait rien à patcher.
|
||||
|
||||
### Fix appliqué
|
||||
|
||||
Corriger les offsets dans le script Python :
|
||||
```python
|
||||
vn_aux = struct.unpack_from(f'{endian}I', data, vneed_pos + 8)[0] # +8, pas +4
|
||||
vn_next = struct.unpack_from(f'{endian}I', data, vneed_pos + 12)[0] # +12, pas +8
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Blocage 4 — Patcher vers GLIBCXX_3.4.29 mais le symbole n'y existe pas
|
||||
|
||||
### Ce qui a été tenté
|
||||
|
||||
Rediriger la dépendance de `GLIBCXX_3.4.30` vers `GLIBCXX_3.4.29` (version juste en dessous, disponible sur AlmaLinux 9).
|
||||
|
||||
### Pourquoi ça échoue
|
||||
|
||||
Le symbole `_ZNSt18condition_variable4waitERSt11unique_lockISt5mutexE` n'existe **pas** sous `GLIBCXX_3.4.29` dans la libstdc++ système. Il est exporté sous `GLIBCXX_3.4.11` :
|
||||
|
||||
```bash
|
||||
$ nm -D /lib64/libstdc++.so.6 | grep condition_variable | grep wait
|
||||
...
|
||||
_ZNSt18condition_variable4waitERSt11unique_lockISt5mutexE@@GLIBCXX_3.4.11
|
||||
```
|
||||
|
||||
Patcher vers `.4.29` donnait encore `version GLIBCXX_3.4.29 not found` (pas de ce symbole sous cette version).
|
||||
|
||||
### Fix appliqué
|
||||
|
||||
Cibler `GLIBCXX_3.4.11` dans le patcher — c'est là que le symbole vit réellement :
|
||||
|
||||
```python
|
||||
old_ver = b'GLIBCXX_3.4.30'
|
||||
new_ver = b'GLIBCXX_3.4.11' # même longueur → patch in-place sûr
|
||||
old_hash = elf_hash('GLIBCXX_3.4.30')
|
||||
new_hash = elf_hash('GLIBCXX_3.4.11')
|
||||
```
|
||||
|
||||
Les deux chaînes font 14 octets → remplacement in-place sans décalage de la table de chaînes.
|
||||
|
||||
---
|
||||
|
||||
## Blocage 5 — `bytearray.index(0, ...)` lève ValueError
|
||||
|
||||
### Ce qui a été tenté
|
||||
|
||||
Lire le nom de version depuis `.dynstr` :
|
||||
```python
|
||||
name_end = data.index(0, name_off) # cherche le '\0' terminal
|
||||
```
|
||||
|
||||
### Pourquoi ça échoue
|
||||
|
||||
`bytearray.index()` attend un entier ou une séquence d'octets selon le contexte, mais la forme `data.index(0, ...)` sur `bytearray` lève `ValueError: 0 is not in bytearray` (comportement différent de `bytes`).
|
||||
|
||||
### Fix appliqué
|
||||
|
||||
```python
|
||||
name_end = data.index(b'\x00', name_off) # forme bytes, toujours valide
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Blocage 6 — `gcc -lstdc++` introuvable
|
||||
|
||||
### Ce qui a été tenté
|
||||
|
||||
Compiler le shim LD_PRELOAD (approche abandonnée) avec `-lstdc++`.
|
||||
|
||||
### Pourquoi ça échoue
|
||||
|
||||
AlmaLinux 9 ne fournit pas le lien symbolique `libstdc++.so` (sans version) nécessaire au linker. Seul `libstdc++.so.6` (avec version) est présent.
|
||||
|
||||
### Fix appliqué
|
||||
|
||||
Passer le chemin complet :
|
||||
```bash
|
||||
gcc -shared -fPIC -Wl,--version-script=glibcxx_compat.map \
|
||||
-o libglibcxx_compat.so glibcxx_compat.c /usr/lib64/libstdc++.so.6
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Blocage 7 — Systemd tuait le daemon après démarrage (`lms server start` quitte après 0)
|
||||
|
||||
### Ce qui a été tenté
|
||||
|
||||
Service `Type=simple` avec `ExecStart=lms server start --port 1234 --bind 0.0.0.0`.
|
||||
|
||||
### Comportement observé
|
||||
|
||||
`lms server start` :
|
||||
1. Lance le daemon `llmster` en arrière-plan
|
||||
2. Attend que le serveur HTTP soit prêt
|
||||
3. Affiche `Success! Server is now running on port 1234`
|
||||
4. **Quitte avec code 0**
|
||||
|
||||
Systemd détecte la fin du processus principal → `KillMode=control-group` (défaut) → tue tout le cgroup → `llmster` mort → service `inactive`.
|
||||
|
||||
Le daemon survivait parfois grâce à un double-fork qui lui permettait d'échapper au cgroup, mais c'était non fiable et `systemctl status` montrait `inactive` même quand le serveur tournait.
|
||||
|
||||
### Ce qui n'a pas fonctionné en intermédiaire
|
||||
|
||||
Lancer `llmster` directement via `ExecStart` + `ExecStartPost=lms server start` :
|
||||
|
||||
```ini
|
||||
ExecStart=/opt/lmstudio/.lmstudio/llmster/0.0.12-1/llmster
|
||||
ExecStartPost=/bin/bash -c 'sleep 5 && lms server start --port 1234 --bind 0.0.0.0'
|
||||
```
|
||||
|
||||
Erreur :
|
||||
```
|
||||
Failed to authenticate: Invalid passkey for lms CLI client.
|
||||
```
|
||||
|
||||
`lms server start` utilise un passkey stocké/généré lors du démarrage du daemon via `lms`. Démarrer `llmster` directement bypasse ce mécanisme → authentification impossible depuis `lms`.
|
||||
|
||||
### Fix appliqué — wrapper `run-llmster.sh`
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
LMS="${HOME}/.lmstudio/bin/lms"
|
||||
|
||||
# lms gère le passkey ET le démarrage du daemon
|
||||
"$LMS" server start --port "$LMS_PORT" --bind "$LMS_BIND"
|
||||
|
||||
# Rester en vie tant que llmster tourne → systemd garde le service "active"
|
||||
while pgrep -u "$(id -un)" -x llmster > /dev/null 2>&1; do
|
||||
sleep 15
|
||||
done
|
||||
|
||||
exit 1 # déclenche Restart=on-failure si le daemon meurt
|
||||
```
|
||||
|
||||
Résultat :
|
||||
- Le wrapper est le processus principal (`Type=simple`)
|
||||
- `lms server start` gère correctement passkey + démarrage du daemon
|
||||
- La boucle maintient le service `active (running)` dans systemd
|
||||
- Si `llmster` meurt, le wrapper sort avec code 1 → `Restart=on-failure` relance tout
|
||||
|
||||
---
|
||||
|
||||
## Blocage 8 — Port 1234 inaccessible depuis le réseau
|
||||
|
||||
### Symptôme
|
||||
|
||||
`curl http://192.168.10.20:1234/v1/models` depuis storage-01 → `No route to host`
|
||||
|
||||
Le serveur écoutait bien sur `0.0.0.0:1234` mais le pare-feu AlmaLinux bloquait le port.
|
||||
|
||||
### Fix appliqué
|
||||
|
||||
```bash
|
||||
firewall-cmd --permanent --add-port=1234/tcp
|
||||
firewall-cmd --reload
|
||||
```
|
||||
|
||||
Ajouté dans `tasks/main.yml` via `ansible.posix.firewalld` pour idempotence.
|
||||
|
||||
---
|
||||
|
||||
## Solution finale
|
||||
|
||||
| Composant | Approche retenue |
|
||||
|---|---|
|
||||
| Dépendance GLIBCXX | Patch ELF de `watcher.node` : réécriture de `vna_hash` + chaîne `GLIBCXX_3.4.30` → `GLIBCXX_3.4.11` |
|
||||
| Service systemd | Wrapper shell : `lms server start` + boucle de monitoring `pgrep llmster` |
|
||||
| Firewall | `firewall-cmd --permanent --add-port=1234/tcp` |
|
||||
|
||||
### Fichiers du rôle Ansible
|
||||
|
||||
```
|
||||
roles/lm_studio/
|
||||
├── files/
|
||||
│ ├── patch-glibcxx-compat.py # parseur ELF, patch hash+string VERNEED
|
||||
│ ├── install-lmstudio-compat.sh # télécharge bundle, patche watcher.node, bootstrap
|
||||
│ └── run-llmster.sh # wrapper systemd
|
||||
└── templates/
|
||||
└── lm-studio.service.j2 # Type=simple, ExecStart=run-llmster.sh
|
||||
```
|
||||
|
||||
### Vérification
|
||||
|
||||
```bash
|
||||
# Depuis storage-01
|
||||
curl http://192.168.10.20:1234/v1/models
|
||||
# → {"data":[...],"object":"list"}
|
||||
|
||||
# Sur gpu-01
|
||||
systemctl status lm-studio
|
||||
# → active (running), MainPID=run-llmster.sh, llmster dans le cgroup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Blocage 9 — llmster rebind sur 127.0.0.1 après chargement Vulkan
|
||||
|
||||
### Symptôme
|
||||
|
||||
`lms server start --bind 0.0.0.0` démarre correctement, mais après
|
||||
`lms load <modele> --gpu max` avec le backend Vulkan, llmster rebind son
|
||||
serveur HTTP sur `127.0.0.1:1234`. Le port 1234 devient inaccessible depuis
|
||||
le réseau LAN.
|
||||
|
||||
```bash
|
||||
# Sur gpu-01 après lms load :
|
||||
ss -tlnp | grep 1234
|
||||
# LISTEN 0 128 127.0.0.1:1234 ← loopback seulement
|
||||
```
|
||||
|
||||
### Cause
|
||||
|
||||
Comportement interne de llmster lors de l'initialisation du backend Vulkan :
|
||||
le processus recrée son socket HTTP et le bind sur loopback plutôt que
|
||||
sur l'adresse configurée au démarrage.
|
||||
|
||||
### Fix appliqué — socat proxy dans run-llmster.sh
|
||||
|
||||
Après le chargement du modèle, un proxy socat est lancé sur l'IP LAN pour
|
||||
forwarder vers le loopback. Les deux sockets sont non-conflictuels (IPs
|
||||
différentes) :
|
||||
|
||||
```bash
|
||||
LAN_IP=$(ip -4 addr show scope global | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -1)
|
||||
socat TCP-LISTEN:1234,fork,bind="${LAN_IP}",reuseaddr TCP:127.0.0.1:1234 &
|
||||
```
|
||||
|
||||
**Pourquoi ne pas relancer `lms server start --bind 0.0.0.0` ?**
|
||||
Relancer `lms server start` après le chargement recharge le modèle avec le
|
||||
contexte par défaut (4096 tokens), écrasant le `--context-length 65536`
|
||||
passé à `lms load`.
|
||||
|
||||
---
|
||||
|
||||
## Blocage 10 — Gemma 4 crash Vulkan sur prompts longs (>800 tokens)
|
||||
|
||||
### Symptôme
|
||||
|
||||
Avec `google/gemma-4-e4b` ou `google/gemma-4-e2b`, la première inférence
|
||||
avec un prompt de plus de ~800 tokens provoque un crash du processus llmster :
|
||||
|
||||
```
|
||||
HTTP 400 {"error":"The model has crashed without additional information. (Exit code: null)"}
|
||||
```
|
||||
|
||||
Après le crash, llmster recharge le modèle avec le contexte par défaut (4096),
|
||||
et les requêtes suivantes échouent avec `n_keep >= n_ctx`.
|
||||
|
||||
### Cause
|
||||
|
||||
Bug dans l'implémentation Vulkan de llama.cpp pour l'architecture Gemma 4.
|
||||
Gemma 4 utilise une attention **interleaved global/local** avec un sliding
|
||||
window de 512 tokens. Quand le prompt dépasse 512 tokens, les layers
|
||||
d'attention globale crashent dans le shader Vulkan.
|
||||
|
||||
Seuil confirmé par test binaire :
|
||||
- OK à 500 tokens (~100x "You are a helpful assistant.")
|
||||
- CRASH à ~900 tokens (~130x)
|
||||
|
||||
La VRAM n'est pas en cause : avec GQA aggressif et sliding window, le KV
|
||||
cache de Gemma 4 à 65536 tokens est ~2 GB seulement (bien dans les 12GB VRAM).
|
||||
|
||||
### Fix
|
||||
|
||||
Utiliser un modèle avec attention GQA standard (sans sliding window interleaved) :
|
||||
- **Qwen3.5-9B** ✓ — support Vulkan mature, GQA standard
|
||||
- **Qwen2.5-7B-Instruct** ✓ — alternative
|
||||
|
||||
Ne pas utiliser Gemma 4 E2B ou E4B avec le backend Vulkan de LM Studio jusqu'à
|
||||
correction du bug upstream dans llama.cpp.
|
||||
18
ansible/roles/lm_studio/defaults/main.yml
Normal file
18
ansible/roles/lm_studio/defaults/main.yml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
lm_studio_user: lmstudio
|
||||
lm_studio_home: /opt/lmstudio
|
||||
lm_studio_port: 1234
|
||||
lm_studio_host: "0.0.0.0"
|
||||
lm_studio_context_length: 65536
|
||||
lm_studio_gpu_offload: "max"
|
||||
|
||||
# HSA override for unofficially supported GPUs (e.g. RX 6700XT = gfx1031 → 10.3.0)
|
||||
hsa_override_gfx_version: "10.3.0"
|
||||
|
||||
# Modèle chargé au démarrage du service
|
||||
# Format HuggingFace : "organization/repo/fichier.gguf"
|
||||
# Laisser vide pour ne pas auto-charger (chargement manuel via lms load)
|
||||
lm_studio_default_model: ""
|
||||
|
||||
# Chemin NFS où stocker les modèles (laisse vide = stockage local par défaut)
|
||||
lm_studio_models_nfs_path: ""
|
||||
57
ansible/roles/lm_studio/files/install-lmstudio-compat.sh
Normal file
57
ansible/roles/lm_studio/files/install-lmstudio-compat.sh
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
#!/usr/bin/env bash
|
||||
# Install LM Studio on AlmaLinux 9.
|
||||
#
|
||||
# AlmaLinux 9 ships GCC 11 → libstdc++ provides up to GLIBCXX_3.4.29.
|
||||
# LM Studio bundles watcher.node (compiled with GCC 12) which requires
|
||||
# _ZNSt18condition_variable4waitERSt11unique_lockISt5mutexE@GLIBCXX_3.4.30.
|
||||
# That symbol exists in the system libstdc++ under GLIBCXX_3.4.11.
|
||||
#
|
||||
# Fix: ELF-patch watcher.node to rewrite the VERNEED entry from
|
||||
# GLIBCXX_3.4.30 → GLIBCXX_3.4.11 (same symbol, correct version tag).
|
||||
# Both vna_hash and the version string are updated in-place.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Ansible copy task places files in /tmp
|
||||
if [ -f "/tmp/patch-glibcxx-compat.py" ]; then
|
||||
PATCHER="/tmp/patch-glibcxx-compat.py"
|
||||
else
|
||||
PATCHER="${SCRIPT_DIR}/patch-glibcxx-compat.py"
|
||||
fi
|
||||
|
||||
# --- Fetch installer metadata ---
|
||||
echo "[lmstudio-install] Fetching installer metadata..."
|
||||
INSTALL_SH=$(curl -fsSL https://lmstudio.ai/install.sh)
|
||||
VERSION=$(echo "$INSTALL_SH" | grep '^APP_VERSION=' | cut -d'"' -f2)
|
||||
ARTIFACT_URL_BASE=$(echo "$INSTALL_SH" | grep '^ARTIFACT_DOWNLOAD_URL=' | cut -d'"' -f2)
|
||||
|
||||
ARTIFACT="${VERSION}-linux-x64.full.tar.gz"
|
||||
URL="https://${ARTIFACT_URL_BASE}/${ARTIFACT}"
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
|
||||
echo "[lmstudio-install] Downloading ${ARTIFACT}..."
|
||||
curl -fsSL "$URL" -o "$TMPDIR/bundle.tar.gz"
|
||||
|
||||
echo "[lmstudio-install] Extracting bundle..."
|
||||
tar xf "$TMPDIR/bundle.tar.gz" -C "$TMPDIR"
|
||||
|
||||
# Patch watcher.node before bootstrap so the daemon never sees GLIBCXX_3.4.30
|
||||
echo "[lmstudio-install] Patching watcher.node (GLIBCXX_3.4.30 → GLIBCXX_3.4.11)..."
|
||||
find "$TMPDIR" -name 'watcher.node' | while read -r node; do
|
||||
python3 "$PATCHER" "$node"
|
||||
done
|
||||
|
||||
echo "[lmstudio-install] Running bootstrap..."
|
||||
LMS_BOOTSTRAP_INSTALL_SH=1 "$TMPDIR/llmster" bootstrap
|
||||
|
||||
# Also patch the installed copy in ~/.lmstudio after bootstrap
|
||||
echo "[lmstudio-install] Patching installed watcher.node copies..."
|
||||
find "${HOME}/.lmstudio" -name 'watcher.node' 2>/dev/null | while read -r node; do
|
||||
python3 "$PATCHER" "$node"
|
||||
done
|
||||
|
||||
echo "[lmstudio-install] Installation complete."
|
||||
147
ansible/roles/lm_studio/files/patch-glibcxx-compat.py
Normal file
147
ansible/roles/lm_studio/files/patch-glibcxx-compat.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Patch ELF .so/.node files that require GLIBCXX_3.4.30 to use GLIBCXX_3.4.29
|
||||
instead. Required for AlmaLinux 9 / RHEL 9 which ships GCC 11 (max GLIBCXX_3.4.29).
|
||||
|
||||
Both the version string AND the vna_hash in .gnu.version_r are updated so the
|
||||
dynamic linker finds the version correctly at runtime.
|
||||
"""
|
||||
import sys
|
||||
import struct
|
||||
|
||||
|
||||
def elf_hash(name: str) -> int:
|
||||
"""ELF version symbol hash (not GNU hash — used in .gnu.version_r)."""
|
||||
h = 0
|
||||
for c in name:
|
||||
h = ((h << 4) + ord(c)) & 0xFFFFFFFF
|
||||
g = h & 0xF0000000
|
||||
if g:
|
||||
h ^= g >> 24
|
||||
h &= (~g) & 0xFFFFFFFF
|
||||
return h
|
||||
|
||||
|
||||
def patch_file(path: str) -> bool:
|
||||
with open(path, 'rb') as f:
|
||||
data = bytearray(f.read())
|
||||
|
||||
old_ver = b'GLIBCXX_3.4.30'
|
||||
new_ver = b'GLIBCXX_3.4.11'
|
||||
|
||||
if old_ver not in data:
|
||||
print(f' skip (no GLIBCXX_3.4.30): {path}')
|
||||
return False
|
||||
|
||||
# ----- ELF header -----
|
||||
if data[:4] != b'\x7fELF':
|
||||
print(f' skip (not ELF): {path}')
|
||||
return False
|
||||
|
||||
ei_class = data[4] # 1=32-bit, 2=64-bit
|
||||
ei_data = data[5] # 1=LE, 2=BE
|
||||
endian = '<' if ei_data == 1 else '>'
|
||||
|
||||
if ei_class == 2:
|
||||
# 64-bit ELF
|
||||
e_shoff_fmt = f'{endian}Q'
|
||||
ehdr_size = 64
|
||||
shdr_size = 64
|
||||
shdr_fmt = f'{endian}IIQQQQIIQQ' # 64-byte shdr
|
||||
(e_shoff,) = struct.unpack_from(f'{endian}Q', data, 40)
|
||||
(e_shentsize, e_shnum, e_shstrndx) = struct.unpack_from(f'{endian}HHH', data, 58)
|
||||
shdr_type_off = 4
|
||||
shdr_off_off = 24 # sh_offset within shdr
|
||||
shdr_link_off = 48 # sh_link within shdr
|
||||
else:
|
||||
# 32-bit ELF
|
||||
(e_shoff,) = struct.unpack_from(f'{endian}I', data, 32)
|
||||
(e_shentsize, e_shnum, e_shstrndx) = struct.unpack_from(f'{endian}HHH', data, 46)
|
||||
shdr_type_off = 4
|
||||
shdr_off_off = 16
|
||||
shdr_link_off = 28
|
||||
|
||||
SHT_GNU_verneed = 0x6FFFFFFE
|
||||
|
||||
patched = False
|
||||
# Target GLIBCXX_3.4.11 — that's where condition_variable::wait actually lives
|
||||
# in AlmaLinux 9's libstdc++.so.6 (same byte length: 14 chars each)
|
||||
old_hash = elf_hash('GLIBCXX_3.4.30')
|
||||
new_hash = elf_hash('GLIBCXX_3.4.11')
|
||||
old_hash_bytes = struct.pack(f'{endian}I', old_hash)
|
||||
new_hash_bytes = struct.pack(f'{endian}I', new_hash)
|
||||
|
||||
for i in range(e_shnum):
|
||||
shdr_base = e_shoff + i * e_shentsize
|
||||
sh_type = struct.unpack_from(f'{endian}I', data, shdr_base + shdr_type_off)[0]
|
||||
|
||||
if sh_type != SHT_GNU_verneed:
|
||||
continue
|
||||
|
||||
# sh_offset and sh_link (index of associated string table section)
|
||||
if ei_class == 2:
|
||||
sh_offset = struct.unpack_from(f'{endian}Q', data, shdr_base + 24)[0]
|
||||
sh_size = struct.unpack_from(f'{endian}Q', data, shdr_base + 32)[0]
|
||||
sh_link = struct.unpack_from(f'{endian}I', data, shdr_base + 40)[0]
|
||||
sh_info = struct.unpack_from(f'{endian}I', data, shdr_base + 44)[0]
|
||||
else:
|
||||
sh_offset = struct.unpack_from(f'{endian}I', data, shdr_base + 16)[0]
|
||||
sh_size = struct.unpack_from(f'{endian}I', data, shdr_base + 20)[0]
|
||||
sh_link = struct.unpack_from(f'{endian}I', data, shdr_base + 24)[0]
|
||||
sh_info = struct.unpack_from(f'{endian}I', data, shdr_base + 28)[0]
|
||||
|
||||
# String table section referenced by sh_link
|
||||
strtab_shdr = e_shoff + sh_link * e_shentsize
|
||||
if ei_class == 2:
|
||||
strtab_off = struct.unpack_from(f'{endian}Q', data, strtab_shdr + 24)[0]
|
||||
else:
|
||||
strtab_off = struct.unpack_from(f'{endian}I', data, strtab_shdr + 16)[0]
|
||||
|
||||
# Walk Elf64_Verneed / Elf64_Vernaux entries
|
||||
# Elf64_Verneed: vn_version(2)+vn_cnt(2)+vn_file(4)+vn_aux(4)+vn_next(4) = 16 bytes
|
||||
# Elf64_Vernaux: vna_hash(4)+vna_flags(2)+vna_other(2)+vna_name(4)+vna_next(4) = 16 bytes
|
||||
vneed_pos = sh_offset
|
||||
for _ in range(sh_info): # sh_info = number of Verneed entries
|
||||
vn_cnt = struct.unpack_from(f'{endian}H', data, vneed_pos + 2)[0]
|
||||
vn_aux = struct.unpack_from(f'{endian}I', data, vneed_pos + 8)[0] # +8 (after vn_file)
|
||||
vn_next = struct.unpack_from(f'{endian}I', data, vneed_pos + 12)[0]
|
||||
|
||||
vaux_pos = vneed_pos + vn_aux
|
||||
for _ in range(vn_cnt):
|
||||
vna_hash = struct.unpack_from(f'{endian}I', data, vaux_pos)[0]
|
||||
vna_name = struct.unpack_from(f'{endian}I', data, vaux_pos + 8)[0]
|
||||
vna_next = struct.unpack_from(f'{endian}I', data, vaux_pos + 12)[0]
|
||||
|
||||
# Read the version name from the string table
|
||||
name_off = strtab_off + vna_name
|
||||
name_end = data.index(b'\x00', name_off)
|
||||
ver_name = bytes(data[name_off:name_end])
|
||||
|
||||
if ver_name == old_ver:
|
||||
# Patch hash
|
||||
struct.pack_into(f'{endian}I', data, vaux_pos, new_hash)
|
||||
# Patch string (same byte length — safe in-place)
|
||||
data[name_off:name_off + len(old_ver)] = new_ver
|
||||
patched = True
|
||||
|
||||
if vna_next == 0:
|
||||
break
|
||||
vaux_pos += vna_next
|
||||
|
||||
if vn_next == 0:
|
||||
break
|
||||
vneed_pos += vn_next
|
||||
|
||||
if patched:
|
||||
with open(path, 'wb') as f:
|
||||
f.write(data)
|
||||
print(f' patched (hash+string): {path}')
|
||||
return True
|
||||
else:
|
||||
print(f' skip (GLIBCXX_3.4.30 not in verneed): {path}')
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
for path in sys.argv[1:]:
|
||||
patch_file(path)
|
||||
45
ansible/roles/lm_studio/files/run-llmster.sh
Normal file
45
ansible/roles/lm_studio/files/run-llmster.sh
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
#!/bin/bash
|
||||
# LM Studio service wrapper.
|
||||
#
|
||||
# Lets lms server start handle daemon launch (passkey setup, etc.),
|
||||
# optionally auto-loads a model (LMS_MODEL + LMS_CONTEXT_LENGTH),
|
||||
# then monitors the llmster process and exits when it dies so systemd
|
||||
# can restart the service.
|
||||
set -e
|
||||
|
||||
LMS="${HOME}/.lmstudio/bin/lms"
|
||||
LMS_PORT="${LMS_PORT:-1234}"
|
||||
LMS_BIND="${LMS_BIND:-0.0.0.0}"
|
||||
LMS_MODEL="${LMS_MODEL:-}"
|
||||
LMS_CONTEXT_LENGTH="${LMS_CONTEXT_LENGTH:-65536}"
|
||||
LMS_GPU_OFFLOAD="${LMS_GPU_OFFLOAD:-max}"
|
||||
|
||||
echo "Starting LM Studio server (port ${LMS_PORT}, bind ${LMS_BIND})..."
|
||||
"$LMS" server start --port "$LMS_PORT" --bind "$LMS_BIND"
|
||||
|
||||
if [[ -n "$LMS_MODEL" ]]; then
|
||||
echo "Unloading any previously loaded models..."
|
||||
"$LMS" unload --all 2>/dev/null || true
|
||||
echo "Auto-loading model ${LMS_MODEL} (context ${LMS_CONTEXT_LENGTH}, gpu ${LMS_GPU_OFFLOAD})..."
|
||||
"$LMS" load "$LMS_MODEL" --context-length "$LMS_CONTEXT_LENGTH" --gpu "$LMS_GPU_OFFLOAD"
|
||||
fi
|
||||
|
||||
# llmster bind sur 127.0.0.1 après chargement du backend Vulkan.
|
||||
# socat proxy sur l'IP LAN (non-conflictuel avec le loopback) → llmster.
|
||||
if [[ "$LMS_BIND" != "127.0.0.1" ]]; then
|
||||
LAN_IP=$(ip -4 addr show scope global | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -1)
|
||||
if [[ -n "$LAN_IP" ]]; then
|
||||
echo "Starting socat proxy ${LAN_IP}:${LMS_PORT} → 127.0.0.1:${LMS_PORT}..."
|
||||
socat TCP-LISTEN:"${LMS_PORT}",fork,bind="${LAN_IP}",reuseaddr TCP:127.0.0.1:"${LMS_PORT}" &
|
||||
SOCAT_PID=$!
|
||||
echo "socat PID: ${SOCAT_PID}"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Server started. Monitoring llmster daemon..."
|
||||
while pgrep -u "$(id -un)" -x llmster > /dev/null 2>&1; do
|
||||
sleep 15
|
||||
done
|
||||
|
||||
echo "llmster daemon stopped unexpectedly, exiting..."
|
||||
exit 1
|
||||
9
ansible/roles/lm_studio/handlers/main.yml
Normal file
9
ansible/roles/lm_studio/handlers/main.yml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
- name: Reload systemd
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
|
||||
- name: Restart lm-studio
|
||||
ansible.builtin.systemd:
|
||||
name: lm-studio
|
||||
state: restarted
|
||||
144
ansible/roles/lm_studio/tasks/main.yml
Normal file
144
ansible/roles/lm_studio/tasks/main.yml
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
---
|
||||
- name: Install LM Studio dependencies
|
||||
ansible.builtin.dnf:
|
||||
name:
|
||||
- fuse
|
||||
- fuse-libs
|
||||
- curl
|
||||
- libatomic
|
||||
- python3
|
||||
- mesa-vulkan-drivers
|
||||
- vulkan-tools
|
||||
- socat
|
||||
state: present
|
||||
|
||||
- name: Create lmstudio system user
|
||||
ansible.builtin.user:
|
||||
name: "{{ lm_studio_user }}"
|
||||
home: "{{ lm_studio_home }}"
|
||||
shell: /bin/bash
|
||||
groups:
|
||||
- video
|
||||
- render
|
||||
append: true
|
||||
system: false
|
||||
create_home: true
|
||||
state: present
|
||||
|
||||
- name: Check if lms CLI is already installed
|
||||
ansible.builtin.stat:
|
||||
path: "{{ lm_studio_home }}/.lmstudio/bin/lms"
|
||||
register: lms_binary
|
||||
|
||||
- name: Copy patch-glibcxx-compat.py
|
||||
ansible.builtin.copy:
|
||||
src: patch-glibcxx-compat.py
|
||||
dest: /tmp/patch-glibcxx-compat.py
|
||||
mode: '0755'
|
||||
|
||||
- name: Copy LM Studio installer files
|
||||
ansible.builtin.copy:
|
||||
src: "{{ item.src }}"
|
||||
dest: "{{ item.dest }}"
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- {src: install-lmstudio-compat.sh, dest: /tmp/install-lmstudio-compat.sh, mode: '0755'}
|
||||
when: not lms_binary.stat.exists
|
||||
|
||||
- name: Run LM Studio installer
|
||||
ansible.builtin.shell:
|
||||
cmd: /tmp/install-lmstudio-compat.sh
|
||||
executable: /bin/bash
|
||||
become: true
|
||||
become_user: "{{ lm_studio_user }}"
|
||||
environment:
|
||||
HOME: "{{ lm_studio_home }}"
|
||||
when: not lms_binary.stat.exists
|
||||
register: lms_install_result
|
||||
|
||||
- name: Show installer output
|
||||
ansible.builtin.debug:
|
||||
var: lms_install_result.stdout_lines
|
||||
when: lms_install_result is changed
|
||||
|
||||
- name: Deploy llmster wrapper script
|
||||
ansible.builtin.copy:
|
||||
src: run-llmster.sh
|
||||
dest: /opt/lmstudio/run-llmster.sh
|
||||
mode: '0755'
|
||||
|
||||
- name: Patch all GLIBCXX_3.4.30-dependent binaries (watcher.node, libllm_engine.so, ...)
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
find "{{ lm_studio_home }}/.lmstudio" \( -name '*.node' -o -name 'lib*.so' \) \
|
||||
-exec python3 /tmp/patch-glibcxx-compat.py {} \;
|
||||
executable: /bin/bash
|
||||
register: patch_result
|
||||
changed_when: "'patched' in patch_result.stdout"
|
||||
|
||||
- name: Show patch output
|
||||
ansible.builtin.debug:
|
||||
var: patch_result.stdout_lines
|
||||
when: patch_result is changed
|
||||
|
||||
- name: Force Vulkan backend (AMD GPU — CPU backend selected by default)
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ lm_studio_home }}/.lmstudio/.internal/backend-preferences-v1.json"
|
||||
owner: "{{ lm_studio_user }}"
|
||||
group: "{{ lm_studio_user }}"
|
||||
mode: '0644'
|
||||
content: |
|
||||
[
|
||||
{
|
||||
"model_format": "gguf",
|
||||
"name": "llama.cpp-linux-x86_64-vulkan-avx2",
|
||||
"version": "2.13.0"
|
||||
}
|
||||
]
|
||||
notify:
|
||||
- Reload systemd
|
||||
- Restart lm-studio
|
||||
|
||||
- name: Deploy LM Studio systemd service
|
||||
ansible.builtin.template:
|
||||
src: lm-studio.service.j2
|
||||
dest: /etc/systemd/system/lm-studio.service
|
||||
mode: '0644'
|
||||
notify:
|
||||
- Reload systemd
|
||||
- Restart lm-studio
|
||||
|
||||
- name: Enable lm-studio service
|
||||
ansible.builtin.systemd:
|
||||
name: lm-studio
|
||||
enabled: true
|
||||
daemon_reload: true
|
||||
state: started
|
||||
|
||||
- name: Redirect models dir vers NFS (symlink)
|
||||
ansible.builtin.file:
|
||||
src: "{{ lm_studio_models_nfs_path }}"
|
||||
dest: "{{ lm_studio_home }}/.lmstudio/models"
|
||||
state: link
|
||||
force: true
|
||||
owner: "{{ lm_studio_user }}"
|
||||
group: "{{ lm_studio_user }}"
|
||||
when: lm_studio_models_nfs_path is defined and lm_studio_models_nfs_path != ""
|
||||
|
||||
- name: Deploy lms global wrapper (/usr/local/bin/lms)
|
||||
ansible.builtin.copy:
|
||||
dest: /usr/local/bin/lms
|
||||
mode: '0755'
|
||||
content: |
|
||||
#!/bin/bash
|
||||
exec sudo -u {{ lm_studio_user }} \
|
||||
HOME={{ lm_studio_home }} \
|
||||
HSA_OVERRIDE_GFX_VERSION={{ hsa_override_gfx_version }} \
|
||||
{{ lm_studio_home }}/.lmstudio/bin/lms "$@"
|
||||
|
||||
- name: Open LM Studio port in firewall
|
||||
ansible.posix.firewalld:
|
||||
port: "{{ lm_studio_port }}/tcp"
|
||||
permanent: true
|
||||
state: enabled
|
||||
immediate: true
|
||||
39
ansible/roles/lm_studio/templates/lm-studio.service.j2
Normal file
39
ansible/roles/lm_studio/templates/lm-studio.service.j2
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
[Unit]
|
||||
Description=LM Studio Server
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User={{ lm_studio_user }}
|
||||
Group={{ lm_studio_user }}
|
||||
|
||||
# Répertoire home pour que lms trouve ses fichiers
|
||||
Environment=HOME={{ lm_studio_home }}
|
||||
|
||||
# ROCm — obligatoire pour RX 6700XT (gfx1031 non officiel)
|
||||
Environment=HSA_OVERRIDE_GFX_VERSION={{ hsa_override_gfx_version }}
|
||||
|
||||
# PATH inclut le binaire lms
|
||||
Environment=PATH={{ lm_studio_home }}/.lmstudio/bin:/usr/local/bin:/usr/bin:/bin
|
||||
|
||||
Environment=LMS_PORT={{ lm_studio_port }}
|
||||
Environment=LMS_BIND={{ lm_studio_host }}
|
||||
Environment=LMS_MODEL={{ lm_studio_default_model }}
|
||||
Environment=LMS_CONTEXT_LENGTH={{ lm_studio_context_length }}
|
||||
Environment=LMS_GPU_OFFLOAD={{ lm_studio_gpu_offload }}
|
||||
|
||||
# Wrapper: runs lms server start, auto-loads model if LMS_MODEL is set, then monitors llmster
|
||||
ExecStart=/opt/lmstudio/run-llmster.sh
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=15
|
||||
TimeoutStartSec=120
|
||||
|
||||
# Journalisation
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=lm-studio
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Loading…
Add table
Add a link
Reference in a new issue