commit b3fce8af5d4a013a2d5a282ed755407b0cc64acc Author: alkatrazz Date: Tue May 12 17:17:03 2026 +0200 feat: initial commit — Ansible roles storage-01/gpu-01 opérationnels diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..450130f --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Python / Ansible env +.venv/ +__pycache__/ +*.pyc + +# Ansible +.vault_pass +*.retry +ansible/.vault_pass + +# Talos (généré par talhelper — ne pas committer) +talos/clusterconfig/ + +# SOPS / age (clés privées — jamais dans git) +*.age +keys.txt + +# Claude Code (settings locaux à la machine) +.claude/settings.local.json + +# Journaux de session (notes de debug, IPs, infos locales) +progress/ + +# Documentation réseau domestique (topologie LAN privée) +Funk/ + +# Docs ops (IPs, architecture détaillée, procédures internes) +admin/ + +# Divers +.DS_Store +*.swp diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2d2ad80 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,207 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Présentation du projet + +**Funk** — homelab IA composé d'un cluster Kubernetes (Talos) pour les services applicatifs, d'un hôte d'inférence LLM dédié (gpu-01 / LM Studio), et d'un agent autonome (Hermes Agent) sur storage-01. Stack IaC complète : Ansible + talhelper + ArgoCD. + +Référence complète : `Funk/Funk-infra/HOMELAB.md` + +**État actuel (2026-05-09)** : +- ✅ Hermes Agent opérationnel sur le poste perso (LM Studio local `127.0.0.1:1234`) +- ✅ storage-01 installé — `192.168.10.1` (LAN cluster), `192.168.1.200` (WAN) +- ✅ gpu-01 installé — ping OK depuis storage-01 (`192.168.10.20`) +- ⏳ Phase 1/2 en cours — Ansible, ROCm, LM Studio, dnsmasq, NAT restent à faire +- ❌ Repo `lab-infra/` à initialiser, aucun cluster k8s monté + +--- + +## Architecture des machines + +| Machine | IP | OS | Rôle | +|---|---|---|---| +| storage-01 | 192.168.10.1 | AlmaLinux 9.7 | Passerelle + bastion admin + données + Hermes Agent — **hors cluster** | +| compute-01 | 192.168.10.11 | Talos v1.13+ | k8s control-plane | +| compute-02 | 192.168.10.12 | Talos v1.13+ | k8s worker | +| compute-03 | 192.168.10.13 | Talos v1.13+ | k8s worker | +| gpu-01 | 192.168.10.20 | AlmaLinux 9.7 | LM Studio dédié (RX 6700XT) — **hors cluster** | + +**Points clés d'architecture :** +- `storage-01` et `gpu-01` sont **hors du cluster k8s** — ils sont consommés via des `ExternalName` services ou `Endpoints` manuels +- Le cluster n'a que 3 nœuds (compute-01/02/03), pas de GPU dans k8s +- Single control-plane assumé : si compute-01 tombe, `kubectl apply` ne fonctionne plus mais les workloads continuent + +--- + +## Structure du repo lab-infra (cible) + +``` +lab-infra/ +├── ansible/ # gère storage-01 + gpu-01 (AlmaLinux) +│ ├── inventory.yml +│ ├── playbooks/site.yml +│ ├── group_vars/ +│ └── roles/ # common, gateway, dnsmasq, nfs_server, postgresql, +│ # qdrant, minio, rocm, lm_studio, hermes_agent +├── talos/ # nœuds Talos via talhelper +│ ├── talconfig.yaml # source de vérité (lue par talhelper) +│ ├── talsecret.sops.yaml +│ ├── talenv.yaml +│ ├── patches/ +│ └── clusterconfig/ # généré → au .gitignore, ne pas committer +└── k8s/ # workloads k8s (ArgoCD) + ├── argocd-bootstrap/ + ├── apps-of-apps/root.yaml + ├── infra/ # metallb, traefik, cert-manager, longhorn, nfs-provisioner + └── apps/ # litellm, open-webui, n8n, monitoring, + # external-services (ExternalName vers storage-01/gpu-01) +``` + +--- + +## Stack IaC — trois outils, trois cibles + +| Outil | Cible | Déclenchement | +|---|---|---| +| **Ansible** | storage-01, gpu-01 (AlmaLinux) | Push manuel : `ansible-playbook` | +| **talhelper** | compute-01/02/03 (Talos) | Push manuel : `talosctl apply-config` | +| **ArgoCD** | Workloads k8s | Pull automatique depuis Git | + +### Ansible + +```bash +cd ansible/ +ansible-playbook -i inventory.yml playbooks/site.yml --check # dry-run +ansible-playbook -i inventory.yml playbooks/site.yml # apply +ansible-playbook -i inventory.yml playbooks/site.yml --tags rocm # un rôle +``` + +Rôles prévus : `common`, `gateway`, `dnsmasq`, `nfs_server`, `postgresql`, `qdrant`, `minio`, `rocm`, `lm_studio`, `hermes_agent`. Tous idempotents. Secrets via Ansible Vault. + +### talhelper (nœuds Talos) + +```bash +cd talos/ +talhelper genconfig # régénérer configs nœuds +talhelper gencommand apply --node compute-02 | bash # appliquer sur un nœud +talhelper gencommand bootstrap --node compute-01 | bash # bootstrap (1ère fois seulement) +talhelper gencommand kubeconfig | bash # récupérer kubeconfig +``` + +`talos/clusterconfig/` est généré — ne pas committer (mettre au `.gitignore`). + +### ArgoCD (workloads k8s) + +Pattern "App of Apps" : modifier un manifest → `git commit` + `git push` → ArgoCD applique automatiquement (poll toutes les ~3 min). + +```bash +vim k8s/apps/open-webui/deployment.yaml +git add k8s/apps/open-webui/ +git commit -m "open-webui: bump to v0.4.0" +git push +``` + +--- + +## Réseaux internes Kubernetes + +- CNI (Flannel) — Pods : `10.42.0.0/16` +- Services : `10.43.0.0/16` +- MetalLB pool (IP virtuelles Traefik/services) : `192.168.10.200-230` + +--- + +## Contrainte GPU critique + +La RX 6700XT (gfx1031) n'est **pas officiellement supportée** par ROCm. Sur gpu-01, toujours s'assurer que la variable est présente dans l'environnement LM Studio / le service systemd : + +``` +HSA_OVERRIDE_GFX_VERSION=10.3.0 +``` + +ROCm tourne directement sur l'hôte AlmaLinux (pas dans des conteneurs). Fallback : Vulkan dans LM Studio si ROCm coince. + +LM Studio bind sur `127.0.0.1` par défaut → pour l'exposer au réseau : +```bash +lms server start --port 1234 --bind 0.0.0.0 +``` + +CLI LM Studio utile : `lms ls`, `lms load `, `lms unload`, `lms server status` + +--- + +## Deux setups IA + +### Setup 1 — Poste perso (9950X3D) — dev local + +| Composant | Endpoint | +|---|---| +| LM Studio | `http://127.0.0.1:1234/v1` | +| Ollama | `http://127.0.0.1:11434` | +| Hermes Agent | `base_url: http://127.0.0.1:1234/v1` | + +```bash +# Prérequis : modèle chargé avec context suffisant (system prompt Hermes ≈ 15 000 tokens) +lms load --context-length 16384 +lms server start --port 1234 +``` + +Agents Goose dans `Funk/Funk-infra/agents/` — pointent tous sur `http://localhost:1234/v1` : +```bash +cd Funk/Funk-infra/agents/ +goose session --config goose-config.yaml +``` + +### Setup 2 — Cluster Funk (production) + +| Composant | Endpoint | +|---|---| +| LM Studio (gpu-01) | `http://192.168.10.20:1234/v1` | +| Hermes Agent | service systemd sur storage-01, pointe sur gpu-01 | + +```bash +# Sur gpu-01 — HSA_OVERRIDE requis pour RX 6700XT (gfx1031) +HSA_OVERRIDE_GFX_VERSION=10.3.0 lms load --context-length 16384 +lms server start --port 1234 --bind 0.0.0.0 +``` + +Détail complet des deux setups : `Funk/Funk-infra/HOMELAB.md` § 7.4 + +--- + +## Conventions + +- **Domaine** : `lab.local` — wildcard DNS via dnsmasq sur storage-01 +- **Ingress** : `.lab.local` → IP MetalLB de Traefik (`192.168.10.200-230`) +- **LM Studio endpoint** : `http://192.168.10.20:1234/v1` +- **Namespaces k8s** : `argocd`, `infra`, `storage`, `external`, `ai`, `workflows` +- **Git** : branches `main` (prod) / `feature/*` — commits en conventional commits (`feat:`, `fix:`, `chore:`, `docs:`) +- **Secrets** : Ansible Vault (Ansible), SOPS+age (Talos via talhelper), Sealed Secrets (k8s) + +## Opérations cluster importantes + +**etcd backup** (single control-plane → critique) : +```bash +talosctl etcd snapshot /path/to/backup.db --nodes compute-01 +``` +À planifier en cron Ansible vers le RAID5 de storage-01. + +**Hermes Agent** — données persistantes dans `~/.hermes/` → doit résider sur le RAID5, pas sur le SSD OS. + +**Ce qui reste toujours manuel** (GitOps ne peut pas bootstrapper lui-même) : +- Installation OS depuis USB (AlmaLinux ou Talos) +- Configuration IP/SSH minimale pour le premier Ansible +- Premier déploiement ArgoCD via Helm + +--- + +## RAM serrée sur compute-02/03 (8 GB) + +Toujours définir `resources.requests` et `resources.limits` sur les pods déployés sur ces nœuds — sans quoi l'OOM-killer peut tuer kubelet. + +Réservations à configurer dans talhelper : +``` +system-reserved: cpu=500m,memory=1Gi +kube-reserved: cpu=500m,memory=1Gi +``` diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4bed79d --- /dev/null +++ b/Makefile @@ -0,0 +1,35 @@ +VENV := .venv +ANSIBLE := $(VENV)/bin/ansible +PLAYBOOK := $(VENV)/bin/ansible-playbook +GALAXY := $(VENV)/bin/ansible-galaxy +INV := ansible/inventory.yml + +.PHONY: install ping check apply apply-storage apply-gpu vault-init + +install: + $(VENV)/bin/pip install -q -r requirements.txt + $(GALAXY) collection install -r ansible/requirements.yml + +ping: + cd ansible && ../$(ANSIBLE) all -m ping + +check: + cd ansible && ../$(PLAYBOOK) playbooks/site.yml --check + +apply: + cd ansible && ../$(PLAYBOOK) playbooks/site.yml + +apply-storage: + cd ansible && ../$(PLAYBOOK) playbooks/storage-01.yml + +apply-gpu: + cd ansible && ../$(PLAYBOOK) playbooks/gpu-01.yml + +vault-encrypt: + cd ansible && ../$(VENV)/bin/ansible-vault encrypt group_vars/all/vault.yml + +vault-edit: + cd ansible && ../$(VENV)/bin/ansible-vault edit group_vars/all/vault.yml + +vault-view: + cd ansible && ../$(VENV)/bin/ansible-vault view group_vars/all/vault.yml diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 0000000..2f8fed2 --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,8 @@ +# Funk — Journal de progression + +| Date | Résumé | +|---|---| +| [2026-05-09](progress/2026-05-09.md) | Init repo, rôles common + gateway, storage-01 et gpu-01 opérationnels | +| [2026-05-10](progress/2026-05-10.md) | dnsmasq, ROCm, LM Studio (GLIBCXX fix), NFS + RAID5, modèles partagés sur NFS | +| [2026-05-10b](progress/2026-05-10b.md) | Rôle hermes_agent — installation, config, service systemd sur storage-01 | +| [2026-05-11](progress/2026-05-11.md) | Hermes opérationnel (matin) — llama-server ROCm 7.x ×4 perf (après-midi) — LiteLLM proxy + Claude API, switch modèle sans restart Hermes (soir) | diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000..9f18f0a --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,15 @@ +[defaults] +inventory = inventory.yml +roles_path = roles +remote_user = ansible +private_key_file = ~/.ssh/id_ed25519 +host_key_checking = False +retry_files_enabled = False +stdout_callback = ansible.builtin.default +result_format = yaml +interpreter_python = auto_silent +vault_password_file = ../.vault_pass + +[privilege_escalation] +become = True +become_method = sudo diff --git a/ansible/group_vars/all/vars.yml b/ansible/group_vars/all/vars.yml new file mode 100644 index 0000000..474fd8b --- /dev/null +++ b/ansible/group_vars/all/vars.yml @@ -0,0 +1,8 @@ +--- +dns_domain: lab.local +ntp_servers: + - 0.fr.pool.ntp.org + - 1.fr.pool.ntp.org + +cluster_network: 192.168.10.0/24 +wan_gateway: 192.168.1.254 diff --git a/ansible/group_vars/all/vault.yml b/ansible/group_vars/all/vault.yml new file mode 100644 index 0000000..faac79e --- /dev/null +++ b/ansible/group_vars/all/vault.yml @@ -0,0 +1,30 @@ +$ANSIBLE_VAULT;1.1;AES256 +33306234383266653631633563663630353937323132323336356161626533373832313530393064 +3130383235333530336531636665623737663261316332370a313739353336306130643461653635 +35353961333236636263613761393935373762336633396337346661373062636131373632366339 +6562383163316162390a393865383736623132653531643064663338393164366166303532633362 +38343131656238653562323632303362303566363635316132383335326462316432336330366230 +63663732356366356234646664646539316531613131313932313237663365643230343063613763 +63633061663833353337626433666363363336646136376334303166613764323634323463393464 +37393533333437386666653663323331666130656161646437663734363161653534666661383339 +38623934643636643361643766356664393335353034373532313262666537373330353762336361 +64343465333534313239323432656135626237333434653034333364663061383965653838663763 +63653832313934326163623831653839613330346238643630663539623436373738323837386162 +66326466623764346666613266663530363664343037643766653562613034363239346639326664 +30346337663530623732333264383839396534383762623338313437633266656665366538633564 +63363633343731636234623330393838313237633064333939663236616365326130323735623339 +34386635623033636166633536633661643133653263646333326363613362303461376432373466 +36613538393334626266396531616366383931636665633862373331343365623962643362306133 +66363730626639336232626430636239643261666661396664396661393635306466363338306662 +35343432346461306330623536363330653262323863343162623965633164653035373432316232 +36303630306465393837373033656130636261316535323464613563656235643136663038366536 +36343062623932346161616134653562346666333266393563623938376535323637376265353164 +65346663373331393561653030356163633038376234663231336332653161636662323537306633 +38636661613535336463616662363033313961613434323033316230653865356233636138343336 +35323438656564613931656538646561663432363234653134323933626236646237326636353833 +36383838666263626632613462646561393035663435373736653861383063623862613131653332 +66393731623630376463623533336133353036386234633361653237626335323432643332313036 +31363032323763656530313230346466343537663664623931623933616166333135326437666231 +30363135643632633962613631656339616665356463653162653832366336663165646264636638 +66663866303733633037313131353033393263346138643139626539326431383762646565613437 +32333462353765653931613431366639323434356365376636646237303166323762 diff --git a/ansible/group_vars/gateway/vars.yml b/ansible/group_vars/gateway/vars.yml new file mode 100644 index 0000000..4313e57 --- /dev/null +++ b/ansible/group_vars/gateway/vars.yml @@ -0,0 +1,5 @@ +--- +wan_interface: enp4s0 # NIC1 — port 2 switch — VLAN 1 (192.168.1.200) +lan_interface: enp6s0f3u2c2 # NIC2 — port 7 switch — VLAN 10 (192.168.10.1) +wan_ip: 192.168.1.200 +lan_ip: 192.168.10.1 diff --git a/ansible/group_vars/gpu_hosts/vars.yml b/ansible/group_vars/gpu_hosts/vars.yml new file mode 100644 index 0000000..abb67f3 --- /dev/null +++ b/ansible/group_vars/gpu_hosts/vars.yml @@ -0,0 +1,2 @@ +--- +hsa_override_gfx_version: "10.3.0" diff --git a/ansible/host_vars/gpu-01/vars.yml b/ansible/host_vars/gpu-01/vars.yml new file mode 100644 index 0000000..8acc216 --- /dev/null +++ b/ansible/host_vars/gpu-01/vars.yml @@ -0,0 +1,14 @@ +--- +ansible_host: 192.168.10.20 + +# llama-server — modèle servi au démarrage +llama_model_path: "/mnt/models/bartowski/Qwen3-8B-GGUF/Qwen3-8B-Q4_K_M.gguf" +llama_model_alias: "qwen3-8b" +llama_ctx_size: 32768 + +# Montages NFS (surcharge les defaults du rôle nfs_client) +nfs_client_mounts: + - local_path: /mnt/nfs + remote_export: /srv/data/nfs/k8s + - local_path: /mnt/models + remote_export: /srv/data/models diff --git a/ansible/host_vars/storage-01/vars.yml b/ansible/host_vars/storage-01/vars.yml new file mode 100644 index 0000000..b58a4a1 --- /dev/null +++ b/ansible/host_vars/storage-01/vars.yml @@ -0,0 +1,21 @@ +--- +ansible_host: 192.168.1.200 + +# RAID5 +raid_uuid: "3add8360-fa01-47eb-8aa1-0e84bacbbc15" +raid_mount: /srv/data + +# NFS exports +nfs_exports: + - path: /srv/data/nfs/k8s + network: "{{ cluster_network }}" + options: "rw,sync,no_subtree_check,no_root_squash" + - path: /srv/data/models + network: "{{ cluster_network }}" + options: "rw,sync,no_subtree_check,no_root_squash" + +# Chemins services (sous le RAID) +hermes_data_dir: /srv/data/hermes +postgres_data_dir: /srv/data/postgres +qdrant_data_dir: /srv/data/qdrant +minio_data_dir: /srv/data/minio diff --git a/ansible/inventory.yml b/ansible/inventory.yml new file mode 100644 index 0000000..668272a --- /dev/null +++ b/ansible/inventory.yml @@ -0,0 +1,18 @@ +all: + vars: + ansible_user: ansible + + children: + gateway: + hosts: + storage-01: + ansible_host: 192.168.1.200 + gpu_hosts: + hosts: + gpu-01: + ansible_host: 192.168.10.20 + ansible_ssh_common_args: '-o StrictHostKeyChecking=no -o ProxyJump=root@192.168.1.200' + almalinux: + children: + gateway: + gpu_hosts: diff --git a/ansible/playbooks/gpu-01.yml b/ansible/playbooks/gpu-01.yml new file mode 100644 index 0000000..29ddb5b --- /dev/null +++ b/ansible/playbooks/gpu-01.yml @@ -0,0 +1,8 @@ +--- +- name: Configure gpu-01 + hosts: gpu_hosts + roles: + - { role: common, tags: [common] } + - { role: rocm, tags: [rocm] } + - { role: nfs_client, tags: [nfs_client] } + - { role: llama_server, tags: [llama_server] } diff --git a/ansible/playbooks/site.yml b/ansible/playbooks/site.yml new file mode 100644 index 0000000..0c0c38b --- /dev/null +++ b/ansible/playbooks/site.yml @@ -0,0 +1,3 @@ +--- +- import_playbook: storage-01.yml +- import_playbook: gpu-01.yml diff --git a/ansible/playbooks/storage-01.yml b/ansible/playbooks/storage-01.yml new file mode 100644 index 0000000..a0eb900 --- /dev/null +++ b/ansible/playbooks/storage-01.yml @@ -0,0 +1,13 @@ +--- +- name: Configure storage-01 + hosts: gateway + roles: + - { role: common, tags: [common] } + - { role: gateway, tags: [gateway] } + - { role: dnsmasq, tags: [dnsmasq] } + - { role: nfs_server, tags: [nfs_server] } + - { role: qdrant, tags: [qdrant] } + - { role: postgresql, tags: [postgresql] } + - { role: minio, tags: [minio] } + - { role: litellm, tags: [litellm] } + - { role: hermes_agent, tags: [hermes_agent] } diff --git a/ansible/requirements.yml b/ansible/requirements.yml new file mode 100644 index 0000000..af5421c --- /dev/null +++ b/ansible/requirements.yml @@ -0,0 +1,4 @@ +collections: + - name: ansible.posix + - name: community.general + - name: community.postgresql diff --git a/ansible/roles/common/defaults/main.yml b/ansible/roles/common/defaults/main.yml new file mode 100644 index 0000000..5d12f5f --- /dev/null +++ b/ansible/roles/common/defaults/main.yml @@ -0,0 +1,19 @@ +--- +timezone: Europe/Paris + +base_packages: + - vim + - curl + - wget + - jq + - git + - htop + - chrony + - sudo + - bash-completion + - tar + - unzip + - net-tools + - bind-utils + +ansible_service_user: ansible diff --git a/ansible/roles/common/handlers/main.yml b/ansible/roles/common/handlers/main.yml new file mode 100644 index 0000000..724ceef --- /dev/null +++ b/ansible/roles/common/handlers/main.yml @@ -0,0 +1,9 @@ +--- +- name: Restart sshd + ansible.builtin.systemd: + name: sshd + state: restarted + +- name: Reboot if kernel updated + ansible.builtin.reboot: + reboot_timeout: 300 diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml new file mode 100644 index 0000000..1d92490 --- /dev/null +++ b/ansible/roles/common/tasks/main.yml @@ -0,0 +1,71 @@ +--- +- name: Set hostname + ansible.builtin.hostname: + name: "{{ inventory_hostname }}" + +- name: Set timezone + community.general.timezone: + name: "{{ timezone }}" + +- name: Enable EPEL repository + ansible.builtin.dnf: + name: epel-release + state: present + +- name: Upgrade all packages + ansible.builtin.dnf: + name: "*" + state: latest + update_cache: true + tags: update + notify: Reboot if kernel updated + +- name: Install base packages + ansible.builtin.dnf: + name: "{{ base_packages }}" + state: present + +- name: Enable and start chrony + ansible.builtin.systemd: + name: chronyd + enabled: true + state: started + +- name: Create ansible group + ansible.builtin.group: + name: "{{ ansible_service_user }}" + state: present + +- name: Create ansible user + ansible.builtin.user: + name: "{{ ansible_service_user }}" + group: "{{ ansible_service_user }}" + groups: wheel + shell: /bin/bash + create_home: true + state: present + +- name: Allow ansible user passwordless sudo + ansible.builtin.copy: + dest: /etc/sudoers.d/ansible + content: "{{ ansible_service_user }} ALL=(ALL) NOPASSWD:ALL\n" + mode: '0440' + validate: /usr/sbin/visudo -cf %s + +- name: Set up SSH authorized key for ansible user + ansible.posix.authorized_key: + user: "{{ ansible_service_user }}" + key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}" + state: present + +- name: Harden sshd + ansible.builtin.lineinfile: + path: /etc/ssh/sshd_config + regexp: "{{ item.regexp }}" + line: "{{ item.line }}" + state: present + loop: + - { regexp: '^#?PermitRootLogin', line: 'PermitRootLogin no' } + - { regexp: '^#?PasswordAuthentication', line: 'PasswordAuthentication no' } + - { regexp: '^#?X11Forwarding', line: 'X11Forwarding no' } + notify: Restart sshd diff --git a/ansible/roles/dnsmasq/defaults/main.yml b/ansible/roles/dnsmasq/defaults/main.yml new file mode 100644 index 0000000..482b92f --- /dev/null +++ b/ansible/roles/dnsmasq/defaults/main.yml @@ -0,0 +1,16 @@ +--- +dns_upstream: + - 8.8.8.8 + - 8.8.4.4 + +dns_cluster_hosts: + - name: storage-01 + ip: 192.168.10.1 + - name: gpu-01 + ip: 192.168.10.20 + - name: compute-01 + ip: 192.168.10.11 + - name: compute-02 + ip: 192.168.10.12 + - name: compute-03 + ip: 192.168.10.13 diff --git a/ansible/roles/dnsmasq/handlers/main.yml b/ansible/roles/dnsmasq/handlers/main.yml new file mode 100644 index 0000000..32bc476 --- /dev/null +++ b/ansible/roles/dnsmasq/handlers/main.yml @@ -0,0 +1,9 @@ +--- +- name: Restart dnsmasq + ansible.builtin.systemd: + name: dnsmasq + state: restarted + +- name: Reload systemd + ansible.builtin.systemd: + daemon_reload: true diff --git a/ansible/roles/dnsmasq/tasks/main.yml b/ansible/roles/dnsmasq/tasks/main.yml new file mode 100644 index 0000000..5d7a28d --- /dev/null +++ b/ansible/roles/dnsmasq/tasks/main.yml @@ -0,0 +1,29 @@ +--- +- name: Install dnsmasq + ansible.builtin.dnf: + name: dnsmasq + state: present + +- name: Deploy dnsmasq config + ansible.builtin.template: + src: dnsmasq.conf.j2 + dest: /etc/dnsmasq.conf + mode: '0644' + notify: Restart dnsmasq + +- name: Deploy dnsmasq systemd override (wait for USB ethernet) + ansible.builtin.copy: + dest: /etc/systemd/system/dnsmasq.service.d/wait-for-network.conf + content: | + [Unit] + After=network-online.target + Wants=network-online.target + mode: '0644' + notify: Reload systemd + +- name: Enable and start dnsmasq + ansible.builtin.systemd: + name: dnsmasq + enabled: true + daemon_reload: true + state: started diff --git a/ansible/roles/dnsmasq/templates/dnsmasq.conf.j2 b/ansible/roles/dnsmasq/templates/dnsmasq.conf.j2 new file mode 100644 index 0000000..0ed7019 --- /dev/null +++ b/ansible/roles/dnsmasq/templates/dnsmasq.conf.j2 @@ -0,0 +1,31 @@ +# Géré par Ansible — ne pas modifier manuellement + +# Écoute uniquement sur le LAN cluster +interface={{ lan_interface }} +bind-interfaces + +# Domaine local +domain={{ dns_domain }} +local=/{{ dns_domain }}/ +expand-hosts + +# Ne pas lire /etc/resolv.conf pour les upstream +no-resolv + +# Upstream DNS pour tout ce qui n'est pas lab.local +{% for server in dns_upstream %} +server={{ server }} +{% endfor %} + +# Enregistrements statiques des machines du cluster +{% for host in dns_cluster_hosts %} +address=/{{ host.name }}.{{ dns_domain }}/{{ host.ip }} +address=/{{ host.name }}/{{ host.ip }} +{% endfor %} + +# Cache +cache-size=1000 + +# Logs (désactiver en prod si trop verbeux) +log-queries +log-facility=/var/log/dnsmasq.log diff --git a/ansible/roles/gateway/handlers/main.yml b/ansible/roles/gateway/handlers/main.yml new file mode 100644 index 0000000..69e8f72 --- /dev/null +++ b/ansible/roles/gateway/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Reload nftables + ansible.builtin.systemd: + name: nftables + state: reloaded diff --git a/ansible/roles/gateway/tasks/main.yml b/ansible/roles/gateway/tasks/main.yml new file mode 100644 index 0000000..351850b --- /dev/null +++ b/ansible/roles/gateway/tasks/main.yml @@ -0,0 +1,32 @@ +--- +- name: Stop and disable firewalld + ansible.builtin.systemd: + name: firewalld + enabled: false + state: stopped + +- name: Enable IP forwarding + ansible.posix.sysctl: + name: net.ipv4.ip_forward + value: '1' + sysctl_set: true + state: present + reload: true + +- name: Install nftables + ansible.builtin.dnf: + name: nftables + state: present + +- name: Deploy nftables config + ansible.builtin.template: + src: nftables.conf.j2 + dest: /etc/sysconfig/nftables.conf + mode: '0640' + notify: Reload nftables + +- name: Enable and start nftables + ansible.builtin.systemd: + name: nftables + enabled: true + state: started diff --git a/ansible/roles/gateway/templates/nftables.conf.j2 b/ansible/roles/gateway/templates/nftables.conf.j2 new file mode 100644 index 0000000..b6bbaf7 --- /dev/null +++ b/ansible/roles/gateway/templates/nftables.conf.j2 @@ -0,0 +1,63 @@ +#!/usr/sbin/nft -f +# Géré par Ansible — ne pas modifier manuellement + +flush ruleset + +table inet filter { + chain input { + type filter hook input priority 0; policy drop; + + # Loopback + iif lo accept + + # Connexions établies + ct state established,related accept + + # ICMP + ip protocol icmp accept + ip6 nexthdr icmpv6 accept + + # SSH depuis le LAN domestique et le LAN cluster + tcp dport 22 ip saddr { 192.168.1.0/24, 192.168.10.0/24 } accept + + # DNS (dnsmasq) + udp dport 53 ip saddr 192.168.10.0/24 accept + tcp dport 53 ip saddr 192.168.10.0/24 accept + + # NFS + tcp dport { 111, 2049 } ip saddr 192.168.10.0/24 accept + udp dport { 111, 2049 } ip saddr 192.168.10.0/24 accept + + # Services données (accès cluster uniquement) + tcp dport { 5432, 6333, 6334, 9000, 9001 } ip saddr 192.168.10.0/24 accept + + # Hermes gateway (accès LAN domestique + cluster) + tcp dport 8080 ip saddr { 192.168.1.0/24, 192.168.10.0/24 } accept + + # LiteLLM (localhost uniquement — pas d'accès réseau direct) + # Port 4000 non exposé ici intentionnellement + + log prefix "nft-drop: " drop + } + + chain forward { + type filter hook forward priority 0; policy drop; + + # Forwarding LAN cluster → WAN + iif {{ lan_interface }} oif {{ wan_interface }} ct state new,established,related accept + iif {{ wan_interface }} oif {{ lan_interface }} ct state established,related accept + } + + chain output { + type filter hook output priority 0; policy accept; + } +} + +table ip nat { + chain postrouting { + type nat hook postrouting priority 100; + + # NAT masquerade pour le LAN cluster + oif {{ wan_interface }} ip saddr 192.168.10.0/24 masquerade + } +} diff --git a/ansible/roles/hermes_agent/defaults/main.yml b/ansible/roles/hermes_agent/defaults/main.yml new file mode 100644 index 0000000..b67ac22 --- /dev/null +++ b/ansible/roles/hermes_agent/defaults/main.yml @@ -0,0 +1,12 @@ +--- +hermes_user: hermes +hermes_home: /opt/hermes +hermes_data_dir: /srv/data/hermes + +hermes_lm_base_url: "http://127.0.0.1:4000/v1" +hermes_lm_api_key: "lm-studio" +hermes_model: "hermes-default" +hermes_context_length: 65536 + +hermes_gateway_port: 8080 +hermes_gateway_host: "0.0.0.0" diff --git a/ansible/roles/hermes_agent/handlers/main.yml b/ansible/roles/hermes_agent/handlers/main.yml new file mode 100644 index 0000000..1cf0c3f --- /dev/null +++ b/ansible/roles/hermes_agent/handlers/main.yml @@ -0,0 +1,9 @@ +--- +- name: Reload systemd + ansible.builtin.systemd: + daemon_reload: true + +- name: Restart hermes-agent + ansible.builtin.systemd: + name: hermes-agent + state: restarted diff --git a/ansible/roles/hermes_agent/tasks/main.yml b/ansible/roles/hermes_agent/tasks/main.yml new file mode 100644 index 0000000..6d3a1c7 --- /dev/null +++ b/ansible/roles/hermes_agent/tasks/main.yml @@ -0,0 +1,157 @@ +--- +# --- Dépendances système --- + +- name: Install Hermes Agent dependencies + ansible.builtin.dnf: + name: + - curl + - git + - tar + state: present + +# --- Utilisateur système --- + +- name: Create hermes system user + ansible.builtin.user: + name: "{{ hermes_user }}" + home: "{{ hermes_home }}" + shell: /bin/bash + system: false + create_home: true + state: present + +# --- Environnement shell utilisateur --- + +- name: Set HERMES_HOME in hermes user bashrc + ansible.builtin.lineinfile: + path: "{{ hermes_home }}/.bashrc" + line: "export HERMES_HOME={{ hermes_data_dir }}" + create: true + owner: "{{ hermes_user }}" + group: "{{ hermes_user }}" + mode: '0644' + +- name: Add hermes venv to PATH in bashrc + ansible.builtin.lineinfile: + path: "{{ hermes_home }}/.bashrc" + line: "export PATH={{ hermes_data_dir }}/hermes-agent/venv/bin:$PATH" + create: true + owner: "{{ hermes_user }}" + group: "{{ hermes_user }}" + mode: '0644' + +# --- Répertoire données (RAID5) --- + +- name: Create HERMES_HOME on RAID5 + ansible.builtin.file: + path: "{{ hermes_data_dir }}" + state: directory + owner: "{{ hermes_user }}" + group: "{{ hermes_user }}" + mode: '0750' + +# --- Installation --- + +- name: Check if hermes binary is already installed + ansible.builtin.stat: + path: "{{ hermes_home }}/.local/bin/hermes" + register: hermes_binary + +- name: Install Hermes Agent via installer script + ansible.builtin.shell: + cmd: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash + executable: /bin/bash + become: true + become_user: "{{ hermes_user }}" + environment: + HOME: "{{ hermes_home }}" + HERMES_HOME: "{{ hermes_data_dir }}" + when: not hermes_binary.stat.exists + register: hermes_install_result + +- name: Show installer output + ansible.builtin.debug: + var: hermes_install_result.stdout_lines + when: hermes_install_result is changed + +# --- Packages Python pour les skills --- + +- name: Install Python packages for Hermes skills + ansible.builtin.pip: + name: "qdrant-client>=1.12.0" + executable: "{{ hermes_data_dir }}/hermes-agent/venv/bin/pip" + state: present + become: true + become_user: "{{ hermes_user }}" + when: hermes_binary.stat.exists + +# --- Configuration --- + +- name: Ensure LM_BASE_URL in .env points to LiteLLM + ansible.builtin.lineinfile: + path: "{{ hermes_data_dir }}/.env" + regexp: '^LM_BASE_URL=' + line: "LM_BASE_URL={{ hermes_lm_base_url }}" + create: false + notify: Restart hermes-agent + when: hermes_binary.stat.exists + +- name: Ensure LM_API_KEY in .env is correct + ansible.builtin.lineinfile: + path: "{{ hermes_data_dir }}/.env" + regexp: '^LM_API_KEY=' + line: "LM_API_KEY={{ hermes_lm_api_key }}" + create: false + notify: Restart hermes-agent + when: hermes_binary.stat.exists + +- name: Deploy Hermes config.yaml + ansible.builtin.template: + src: config.yaml.j2 + dest: "{{ hermes_data_dir }}/config.yaml" + owner: "{{ hermes_user }}" + group: "{{ hermes_user }}" + mode: '0640' + notify: Restart hermes-agent + +# --- Service systemd --- + +- name: Deploy hermes-agent systemd service + ansible.builtin.template: + src: hermes-agent.service.j2 + dest: /etc/systemd/system/hermes-agent.service + mode: '0644' + notify: + - Reload systemd + - Restart hermes-agent + +- name: Enable and start hermes-agent + ansible.builtin.systemd: + name: hermes-agent + enabled: true + daemon_reload: true + state: started + +# --- Wrapper global --- + +- name: Deploy hermes global wrapper (/usr/local/bin/hermes) + ansible.builtin.copy: + dest: /usr/local/bin/hermes + mode: '0755' + content: | + #!/bin/bash + exec sudo -u {{ hermes_user }} \ + HOME={{ hermes_home }} \ + HERMES_HOME={{ hermes_data_dir }} \ + {{ hermes_data_dir }}/hermes-agent/venv/bin/hermes "$@" + +# --- Firewall --- + +- name: Open Hermes gateway port in firewall + ansible.posix.firewalld: + port: "{{ hermes_gateway_port }}/tcp" + permanent: true + state: enabled + immediate: true + when: ansible_facts.services['firewalld.service'] is defined + and ansible_facts.services['firewalld.service']['state'] == 'running' diff --git a/ansible/roles/hermes_agent/templates/config.yaml.j2 b/ansible/roles/hermes_agent/templates/config.yaml.j2 new file mode 100644 index 0000000..e109bb0 --- /dev/null +++ b/ansible/roles/hermes_agent/templates/config.yaml.j2 @@ -0,0 +1,13 @@ +model: + provider: lmstudio + base_url: "{{ hermes_lm_base_url }}" + default: "{{ hermes_model }}" + context_length: {{ hermes_context_length }} + +auxiliary: + compression: + context_length: {{ hermes_context_length }} + +gateway: + host: "{{ hermes_gateway_host }}" + port: {{ hermes_gateway_port }} diff --git a/ansible/roles/hermes_agent/templates/hermes-agent.service.j2 b/ansible/roles/hermes_agent/templates/hermes-agent.service.j2 new file mode 100644 index 0000000..cc7ee31 --- /dev/null +++ b/ansible/roles/hermes_agent/templates/hermes-agent.service.j2 @@ -0,0 +1,26 @@ +[Unit] +Description=Hermes Agent Gateway +After=network-online.target +Wants=network-online.target + +[Service] +User={{ hermes_user }} +Group={{ hermes_user }} + +Environment=HOME={{ hermes_home }} +Environment=HERMES_HOME={{ hermes_data_dir }} +Environment=LM_BASE_URL={{ hermes_lm_base_url }} +Environment=LM_API_KEY={{ hermes_lm_api_key }} +Environment=PATH={{ hermes_data_dir }}/hermes-agent/venv/bin:/usr/local/bin:/usr/bin:/bin + +ExecStart={{ hermes_home }}/.local/bin/hermes gateway run + +Restart=on-failure +RestartSec=10s + +StandardOutput=journal +StandardError=journal +SyslogIdentifier=hermes-agent + +[Install] +WantedBy=multi-user.target diff --git a/ansible/roles/litellm/defaults/main.yml b/ansible/roles/litellm/defaults/main.yml new file mode 100644 index 0000000..34a9f09 --- /dev/null +++ b/ansible/roles/litellm/defaults/main.yml @@ -0,0 +1,15 @@ +--- +litellm_user: litellm +litellm_venv: /opt/litellm/venv +litellm_config_dir: /etc/litellm + +litellm_host: "127.0.0.1" # loopback — Hermes est sur la même machine +litellm_port: 4000 +litellm_master_key: "lm-studio" + +# Clé Anthropic — vient du vault +litellm_anthropic_api_key: "{{ vault_anthropic_api_key }}" + +# Endpoint llama-server sur gpu-01 +litellm_local_llm_base_url: "http://192.168.10.20:1234/v1" +litellm_local_llm_api_key: "lm-studio" diff --git a/ansible/roles/litellm/files/hermes-switch b/ansible/roles/litellm/files/hermes-switch new file mode 100644 index 0000000..a43ce83 --- /dev/null +++ b/ansible/roles/litellm/files/hermes-switch @@ -0,0 +1,76 @@ +#!/bin/bash +# Switch le modèle de Hermes via LiteLLM (hermes-default alias) +# Usage: hermes-switch [qwen|claude|status] + +CONFIG=/etc/litellm/config.yaml + +qwen_block=' litellm_params:\n model: openai/qwen2.5-7b-instruct\n api_base: http://192.168.10.20:1234/v1\n api_key: lm-studio' +claude_block=' litellm_params:\n model: anthropic/claude-sonnet-4-6\n api_key: os.environ/ANTHROPIC_API_KEY' + +current() { + grep -A3 "model_name: hermes-default" "$CONFIG" | grep "model:" | awk '{print $2}' +} + +status() { + local cur=$(current) + echo "hermes-default → $cur" +} + +switch_qwen() { + python3 - "$CONFIG" <<'EOF' +import sys, re +path = sys.argv[1] +text = open(path).read() +block = """ - model_name: hermes-default + litellm_params: + model: openai/qwen3-8b + api_base: http://192.168.10.20:1234/v1 + api_key: lm-studio""" +text = re.sub( + r' - model_name: hermes-default\n litellm_params:.*?(?=\n -|\nlitellm_settings)', + block + '\n', + text, flags=re.DOTALL +) +open(path, 'w').write(text) +print("OK") +EOF +} + +switch_claude() { + python3 - "$CONFIG" <<'EOF' +import sys, re +path = sys.argv[1] +text = open(path).read() +block = """ - model_name: hermes-default + litellm_params: + model: anthropic/claude-sonnet-4-6 + api_key: os.environ/ANTHROPIC_API_KEY""" +text = re.sub( + r' - model_name: hermes-default\n litellm_params:.*?(?=\n -|\nlitellm_settings)', + block + '\n', + text, flags=re.DOTALL +) +open(path, 'w').write(text) +print("OK") +EOF +} + +case "${1:-status}" in + qwen) + switch_qwen + systemctl restart litellm + echo "Hermes → Qwen3-8B (gpu-01)" + ;; + claude) + switch_claude + systemctl restart litellm + echo "Hermes → Claude Sonnet 4.6 (API Anthropic)" + ;; + status) + status + ;; + *) + echo "Usage: hermes-switch [qwen|claude|status]" + exit 1 + ;; +esac diff --git a/ansible/roles/litellm/handlers/main.yml b/ansible/roles/litellm/handlers/main.yml new file mode 100644 index 0000000..0b21d82 --- /dev/null +++ b/ansible/roles/litellm/handlers/main.yml @@ -0,0 +1,9 @@ +--- +- name: Reload systemd + ansible.builtin.systemd: + daemon_reload: true + +- name: Restart litellm + ansible.builtin.systemd: + name: litellm + state: restarted diff --git a/ansible/roles/litellm/tasks/main.yml b/ansible/roles/litellm/tasks/main.yml new file mode 100644 index 0000000..d1ce6ef --- /dev/null +++ b/ansible/roles/litellm/tasks/main.yml @@ -0,0 +1,62 @@ +--- +- name: Install Python pip and devel + ansible.builtin.dnf: + name: + - python3-pip + - python3-devel + state: present + +- name: Create litellm system user + ansible.builtin.user: + name: "{{ litellm_user }}" + system: true + shell: /sbin/nologin + home: /opt/litellm + create_home: true + state: present + +- name: Install litellm[proxy] in venv + ansible.builtin.pip: + name: "litellm[proxy]" + virtualenv: "{{ litellm_venv }}" + virtualenv_command: python3 -m venv + state: present + +- name: Create config directory + ansible.builtin.file: + path: "{{ litellm_config_dir }}" + state: directory + owner: "{{ litellm_user }}" + group: "{{ litellm_user }}" + mode: '0750' + +- name: Deploy LiteLLM config + ansible.builtin.template: + src: config.yaml.j2 + dest: "{{ litellm_config_dir }}/config.yaml" + owner: "{{ litellm_user }}" + group: "{{ litellm_user }}" + mode: '0640' + notify: Restart litellm + +- name: Deploy systemd service + ansible.builtin.template: + src: litellm.service.j2 + dest: /etc/systemd/system/litellm.service + mode: '0644' + notify: + - Reload systemd + - Restart litellm + +- name: Deploy hermes-switch script + ansible.builtin.copy: + src: hermes-switch + dest: /usr/local/bin/hermes-switch + mode: '0755' + +- name: Enable and start litellm + ansible.builtin.systemd: + name: litellm + enabled: true + daemon_reload: true + state: started diff --git a/ansible/roles/litellm/templates/config.yaml.j2 b/ansible/roles/litellm/templates/config.yaml.j2 new file mode 100644 index 0000000..931b2e2 --- /dev/null +++ b/ansible/roles/litellm/templates/config.yaml.j2 @@ -0,0 +1,36 @@ +model_list: + + # Alias Hermes — changer uniquement ce bloc pour switcher le modèle de Hermes + # sans toucher à la configuration de Hermes lui-même + - model_name: hermes-default + litellm_params: + model: openai/qwen3-8b + api_base: {{ litellm_local_llm_base_url }} + api_key: {{ litellm_local_llm_api_key }} + # Pour passer Hermes sur Claude : remplacer les 3 lignes ci-dessus par : + # model: anthropic/claude-sonnet-4-6 + # api_key: os.environ/ANTHROPIC_API_KEY + + # Modèles accessibles directement par nom + - model_name: qwen3-8b + litellm_params: + model: openai/qwen3-8b + api_base: {{ litellm_local_llm_base_url }} + api_key: {{ litellm_local_llm_api_key }} + + - model_name: claude-sonnet-4-6 + litellm_params: + model: anthropic/claude-sonnet-4-6 + api_key: os.environ/ANTHROPIC_API_KEY + + - model_name: claude-opus-4-7 + litellm_params: + model: anthropic/claude-opus-4-7 + api_key: os.environ/ANTHROPIC_API_KEY + +litellm_settings: + drop_params: true + request_timeout: 600 + +general_settings: + master_key: {{ litellm_master_key }} diff --git a/ansible/roles/litellm/templates/litellm.service.j2 b/ansible/roles/litellm/templates/litellm.service.j2 new file mode 100644 index 0000000..d3bdc1b --- /dev/null +++ b/ansible/roles/litellm/templates/litellm.service.j2 @@ -0,0 +1,25 @@ +[Unit] +Description=LiteLLM Proxy — unified LLM gateway (Qwen local + Claude API) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User={{ litellm_user }} +Group={{ litellm_user }} + +Environment=ANTHROPIC_API_KEY={{ litellm_anthropic_api_key }} + +ExecStart={{ litellm_venv }}/bin/litellm \ + --config {{ litellm_config_dir }}/config.yaml \ + --host {{ litellm_host }} \ + --port {{ litellm_port }} + +Restart=on-failure +RestartSec=10 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=litellm + +[Install] +WantedBy=multi-user.target diff --git a/ansible/roles/llama_server/defaults/main.yml b/ansible/roles/llama_server/defaults/main.yml new file mode 100644 index 0000000..8318c90 --- /dev/null +++ b/ansible/roles/llama_server/defaults/main.yml @@ -0,0 +1,15 @@ +--- +llama_server_port: 1234 +llama_server_host: "0.0.0.0" +llama_amdgpu_targets: "gfx1030" +rocm_path: "/opt/rocm" +hsa_override_gfx_version: "10.3.0" + +# Modèle à charger au démarrage +llama_model_path: "" +llama_model_alias: "" +llama_ctx_size: 32768 +llama_n_gpu_layers: 99 +llama_parallel: 1 +llama_embeddings: true +llama_pooling: "mean" diff --git a/ansible/roles/llama_server/handlers/main.yml b/ansible/roles/llama_server/handlers/main.yml new file mode 100644 index 0000000..4502bd1 --- /dev/null +++ b/ansible/roles/llama_server/handlers/main.yml @@ -0,0 +1,9 @@ +--- +- name: Reload systemd + ansible.builtin.systemd: + daemon_reload: true + +- name: Restart llama-server + ansible.builtin.systemd: + name: llama-server + state: restarted diff --git a/ansible/roles/llama_server/tasks/main.yml b/ansible/roles/llama_server/tasks/main.yml new file mode 100644 index 0000000..47b8102 --- /dev/null +++ b/ansible/roles/llama_server/tasks/main.yml @@ -0,0 +1,79 @@ +--- +- name: Install build dependencies + ansible.builtin.dnf: + name: + - cmake + - gcc-c++ + - git + - patchelf + - hipblas-devel + - rocblas-devel + - hip-devel + - hipcc + state: present + +- name: Clone llama.cpp + ansible.builtin.git: + repo: https://github.com/ggerganov/llama.cpp + dest: /opt/llama.cpp + depth: 1 + version: "{{ llama_server_commit | default('HEAD') }}" + register: llama_clone + +- name: Check if llama-server binary exists + ansible.builtin.stat: + path: /opt/llama.cpp/build/bin/llama-server + register: llama_server_binary_stat + +- name: Configure cmake build (ROCm HIP) + ansible.builtin.command: + cmd: > + cmake -B build + -DGGML_HIP=ON + -DAMDGPU_TARGETS={{ llama_amdgpu_targets }} + -DCMAKE_BUILD_TYPE=Release + -DROCM_PATH={{ rocm_path }} + -DCMAKE_PREFIX_PATH={{ rocm_path }} + -DCMAKE_HIP_COMPILER={{ rocm_path }}/llvm/bin/clang++ + chdir: /opt/llama.cpp + environment: + PATH: "{{ rocm_path }}/bin:{{ ansible_env.PATH }}" + when: llama_clone is changed or not llama_server_binary_stat.stat.exists + +- name: Build llama-server + ansible.builtin.command: + cmd: cmake --build build --target llama-server -j{{ ansible_processor_nproc }} + chdir: /opt/llama.cpp + environment: + PATH: "{{ rocm_path }}/bin:{{ ansible_env.PATH }}" + when: llama_clone is changed or not llama_server_binary_stat.stat.exists + +- name: Deploy systemd service + ansible.builtin.template: + src: llama-server.service.j2 + dest: /etc/systemd/system/llama-server.service + mode: '0644' + notify: + - Reload systemd + - Restart llama-server + +- name: Enable llama-server service + ansible.builtin.systemd: + name: llama-server + enabled: true + daemon_reload: true + state: started + +- name: Open port in firewall + ansible.posix.firewalld: + port: "{{ llama_server_port }}/tcp" + permanent: true + state: enabled + immediate: true + +- name: Disable lm-studio service (replaced by llama-server) + ansible.builtin.systemd: + name: lm-studio + enabled: false + state: stopped + ignore_errors: true diff --git a/ansible/roles/llama_server/templates/llama-server.service.j2 b/ansible/roles/llama_server/templates/llama-server.service.j2 new file mode 100644 index 0000000..375e1f2 --- /dev/null +++ b/ansible/roles/llama_server/templates/llama-server.service.j2 @@ -0,0 +1,28 @@ +[Unit] +Description=llama-server (llama.cpp OpenAI-compatible API) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +Environment=HSA_OVERRIDE_GFX_VERSION={{ hsa_override_gfx_version }} +Environment=LD_LIBRARY_PATH={{ rocm_path }}/lib +ExecStart=/opt/llama.cpp/build/bin/llama-server \ + --model {{ llama_model_path }} \ + --host {{ llama_server_host }} \ + --port {{ llama_server_port }} \ + --ctx-size {{ llama_ctx_size }} \ + --n-gpu-layers {{ llama_n_gpu_layers }} \ + --parallel {{ llama_parallel }} \ + --alias {{ llama_model_alias }} \ +{% if llama_embeddings %} + --embeddings \ + --pooling {{ llama_pooling }} \ +{% endif %} + --log-disable +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target diff --git a/ansible/roles/lm_studio/TROUBLESHOOTING.md b/ansible/roles/lm_studio/TROUBLESHOOTING.md new file mode 100644 index 0000000..64edb9a --- /dev/null +++ b/ansible/roles/lm_studio/TROUBLESHOOTING.md @@ -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 --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. diff --git a/ansible/roles/lm_studio/defaults/main.yml b/ansible/roles/lm_studio/defaults/main.yml new file mode 100644 index 0000000..bda7028 --- /dev/null +++ b/ansible/roles/lm_studio/defaults/main.yml @@ -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: "" diff --git a/ansible/roles/lm_studio/files/install-lmstudio-compat.sh b/ansible/roles/lm_studio/files/install-lmstudio-compat.sh new file mode 100644 index 0000000..9e0316c --- /dev/null +++ b/ansible/roles/lm_studio/files/install-lmstudio-compat.sh @@ -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." diff --git a/ansible/roles/lm_studio/files/patch-glibcxx-compat.py b/ansible/roles/lm_studio/files/patch-glibcxx-compat.py new file mode 100644 index 0000000..d8c4b88 --- /dev/null +++ b/ansible/roles/lm_studio/files/patch-glibcxx-compat.py @@ -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) diff --git a/ansible/roles/lm_studio/files/run-llmster.sh b/ansible/roles/lm_studio/files/run-llmster.sh new file mode 100644 index 0000000..2cbb8de --- /dev/null +++ b/ansible/roles/lm_studio/files/run-llmster.sh @@ -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 diff --git a/ansible/roles/lm_studio/handlers/main.yml b/ansible/roles/lm_studio/handlers/main.yml new file mode 100644 index 0000000..809c1a2 --- /dev/null +++ b/ansible/roles/lm_studio/handlers/main.yml @@ -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 diff --git a/ansible/roles/lm_studio/tasks/main.yml b/ansible/roles/lm_studio/tasks/main.yml new file mode 100644 index 0000000..152385a --- /dev/null +++ b/ansible/roles/lm_studio/tasks/main.yml @@ -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 diff --git a/ansible/roles/lm_studio/templates/lm-studio.service.j2 b/ansible/roles/lm_studio/templates/lm-studio.service.j2 new file mode 100644 index 0000000..17c1183 --- /dev/null +++ b/ansible/roles/lm_studio/templates/lm-studio.service.j2 @@ -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 diff --git a/ansible/roles/minio/tasks/main.yml b/ansible/roles/minio/tasks/main.yml new file mode 100644 index 0000000..ed97d53 --- /dev/null +++ b/ansible/roles/minio/tasks/main.yml @@ -0,0 +1 @@ +--- diff --git a/ansible/roles/nfs_client/defaults/main.yml b/ansible/roles/nfs_client/defaults/main.yml new file mode 100644 index 0000000..1592194 --- /dev/null +++ b/ansible/roles/nfs_client/defaults/main.yml @@ -0,0 +1,7 @@ +--- +nfs_server_ip: 192.168.10.1 +nfs_server_export: /srv/data/nfs/k8s + +nfs_client_mounts: + - local_path: /mnt/nfs + remote_export: "{{ nfs_server_export }}" diff --git a/ansible/roles/nfs_client/handlers/main.yml b/ansible/roles/nfs_client/handlers/main.yml new file mode 100644 index 0000000..616cfed --- /dev/null +++ b/ansible/roles/nfs_client/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Reload systemd and activate automounts + ansible.builtin.systemd: + daemon_reload: true + listen: Reload systemd and activate automounts diff --git a/ansible/roles/nfs_client/tasks/main.yml b/ansible/roles/nfs_client/tasks/main.yml new file mode 100644 index 0000000..9afe22a --- /dev/null +++ b/ansible/roles/nfs_client/tasks/main.yml @@ -0,0 +1,42 @@ +--- +- name: Install nfs-utils + ansible.builtin.dnf: + name: nfs-utils + state: present + +- name: Create NFS mount points + ansible.builtin.file: + path: "{{ item.local_path }}" + state: directory + mode: '0755' + loop: "{{ nfs_client_mounts }}" + +- name: Write NFS entries in fstab (sans monter immédiatement) + ansible.posix.mount: + src: "{{ nfs_server_ip }}:{{ item.remote_export }}" + path: "{{ item.local_path }}" + fstype: nfs + # Options pour extinctions fréquentes : + # soft → échoue proprement (EIO) si serveur injoignable + # timeo=30 / retrans=3 → 3s timeout, 3 essais avant EIO + # _netdev → montage après réseau disponible seulement + # nofail → n'empêche pas le boot si NFS absent + # x-systemd.automount → monté à la demande (premier accès), pas au boot + # x-systemd.mount-timeout=30 → 30s max pour réussir le montage à la demande + opts: "nfsvers=4,soft,timeo=30,retrans=3,_netdev,nofail,x-systemd.automount,x-systemd.mount-timeout=30" + state: present + dump: 0 + passno: 0 + loop: "{{ nfs_client_mounts }}" + notify: Reload systemd and activate automounts + +- name: Reload systemd et activer les automounts NFS + ansible.builtin.systemd: + daemon_reload: true + +- name: Enable and start NFS automount units + ansible.builtin.systemd: + name: "{{ item.local_path | replace('/', '-') | regex_replace('^-', '') }}.automount" + enabled: true + state: started + loop: "{{ nfs_client_mounts }}" diff --git a/ansible/roles/nfs_server/defaults/main.yml b/ansible/roles/nfs_server/defaults/main.yml new file mode 100644 index 0000000..25e4f22 --- /dev/null +++ b/ansible/roles/nfs_server/defaults/main.yml @@ -0,0 +1,10 @@ +--- +raid_device: /dev/md127 +raid_uuid: "3add8360-fa01-47eb-8aa1-0e84bacbbc15" +raid_fstype: ext4 +raid_mount: /srv/data + +nfs_exports: + - path: /srv/data/nfs/k8s + network: "{{ cluster_network }}" + options: "rw,sync,no_subtree_check,no_root_squash" diff --git a/ansible/roles/nfs_server/handlers/main.yml b/ansible/roles/nfs_server/handlers/main.yml new file mode 100644 index 0000000..d2a68d3 --- /dev/null +++ b/ansible/roles/nfs_server/handlers/main.yml @@ -0,0 +1,14 @@ +--- +- name: Rebuild initramfs + ansible.builtin.command: dracut --force + listen: Rebuild initramfs + +- name: Reload exports + ansible.builtin.command: exportfs -ra + listen: Reload exports + +- name: Restart nfs-server + ansible.builtin.systemd: + name: nfs-server + state: restarted + listen: Restart nfs-server diff --git a/ansible/roles/nfs_server/tasks/main.yml b/ansible/roles/nfs_server/tasks/main.yml new file mode 100644 index 0000000..5e37179 --- /dev/null +++ b/ansible/roles/nfs_server/tasks/main.yml @@ -0,0 +1,83 @@ +--- +- name: Install nfs-utils and mdadm + ansible.builtin.dnf: + name: + - nfs-utils + - mdadm + state: present + +# --- RAID persistence --- + +- name: Persist mdadm array configuration + ansible.builtin.shell: + cmd: mdadm --detail --scan + register: mdadm_scan + changed_when: false + +- name: Write /etc/mdadm.conf + ansible.builtin.copy: + content: "{{ mdadm_scan.stdout }}\n" + dest: /etc/mdadm.conf + mode: '0644' + notify: Rebuild initramfs + +# --- RAID mount --- + +- name: Create RAID mount point + ansible.builtin.file: + path: "{{ raid_mount }}" + state: directory + mode: '0755' + +- name: Mount RAID5 array (fstab + immediate) + ansible.posix.mount: + src: "UUID={{ raid_uuid }}" + path: "{{ raid_mount }}" + fstype: "{{ raid_fstype }}" + opts: defaults,noatime,nodiratime + state: mounted + dump: 0 + passno: 2 + +# --- Répertoires NFS --- + +- name: Create NFS export directories + ansible.builtin.file: + path: "{{ item.path }}" + state: directory + mode: '0755' + owner: nobody + group: nobody + loop: "{{ nfs_exports }}" + +# --- NFS server --- + +- name: Deploy /etc/exports + ansible.builtin.template: + src: exports.j2 + dest: /etc/exports + mode: '0644' + notify: + - Reload exports + - Restart nfs-server + +- name: Enable and start nfs-server + ansible.builtin.systemd: + name: nfs-server + enabled: true + state: started + +# --- Firewall --- + +- name: Allow NFS through firewall (firewalld uniquement, pas nftables) + ansible.posix.firewalld: + service: "{{ item }}" + permanent: true + state: enabled + immediate: true + loop: + - nfs + - mountd + - rpc-bind + when: ansible_facts.services['firewalld.service'] is defined + and ansible_facts.services['firewalld.service']['state'] == 'running' diff --git a/ansible/roles/nfs_server/templates/exports.j2 b/ansible/roles/nfs_server/templates/exports.j2 new file mode 100644 index 0000000..60ed81d --- /dev/null +++ b/ansible/roles/nfs_server/templates/exports.j2 @@ -0,0 +1,4 @@ +# /etc/exports — géré par Ansible, ne pas éditer manuellement +{% for export in nfs_exports %} +{{ export.path }} {{ export.network }}({{ export.options }}) +{% endfor %} diff --git a/ansible/roles/postgresql/defaults/main.yml b/ansible/roles/postgresql/defaults/main.yml new file mode 100644 index 0000000..90e8983 --- /dev/null +++ b/ansible/roles/postgresql/defaults/main.yml @@ -0,0 +1,24 @@ +--- +postgresql_version: 16 +postgresql_port: 5432 +postgresql_data_dir: /srv/data/postgres + +# Écoute sur localhost + LAN cluster (pour les services k8s futurs) +postgresql_listen_addresses: "localhost, 192.168.10.1" + +postgresql_max_connections: 100 +postgresql_shared_buffers: "256MB" + +# Bases et utilisateurs à créer +# Mots de passe dans le vault : vault_pg_hermes_password, vault_pg_litellm_password +postgresql_databases: + - name: hermes + - name: litellm + +postgresql_users: + - name: hermes + password: "{{ vault_pg_hermes_password }}" + db: hermes + - name: litellm + password: "{{ vault_pg_litellm_password }}" + db: litellm diff --git a/ansible/roles/postgresql/handlers/main.yml b/ansible/roles/postgresql/handlers/main.yml new file mode 100644 index 0000000..248a29d --- /dev/null +++ b/ansible/roles/postgresql/handlers/main.yml @@ -0,0 +1,10 @@ +--- +- name: Restart postgresql + ansible.builtin.systemd: + name: "postgresql-{{ postgresql_version }}" + state: restarted + +- name: Reload postgresql + ansible.builtin.systemd: + name: "postgresql-{{ postgresql_version }}" + state: reloaded diff --git a/ansible/roles/postgresql/tasks/main.yml b/ansible/roles/postgresql/tasks/main.yml new file mode 100644 index 0000000..76b2f95 --- /dev/null +++ b/ansible/roles/postgresql/tasks/main.yml @@ -0,0 +1,142 @@ +--- +# --- Dépôt officiel PostgreSQL --- + +- name: Install PostgreSQL repo + ansible.builtin.dnf: + name: "https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm" + state: present + disable_gpg_check: true + +- name: Disable built-in postgresql module + ansible.builtin.command: + cmd: dnf -qy module disable postgresql + changed_when: false + +# --- Installation --- + +- name: Install PostgreSQL {{ postgresql_version }} + ansible.builtin.dnf: + name: + - "postgresql{{ postgresql_version }}-server" + - "postgresql{{ postgresql_version }}" + - python3-psycopg2 + state: present + +# --- Répertoire données sur RAID5 --- + +- name: Create PostgreSQL data directory on RAID5 + ansible.builtin.file: + path: "{{ postgresql_data_dir }}" + state: directory + owner: postgres + group: postgres + mode: '0700' + +# --- Service override pour PGDATA custom --- + +- name: Create systemd override directory + ansible.builtin.file: + path: "/etc/systemd/system/postgresql-{{ postgresql_version }}.service.d" + state: directory + mode: '0755' + +- name: Deploy PGDATA override + ansible.builtin.copy: + dest: "/etc/systemd/system/postgresql-{{ postgresql_version }}.service.d/pgdata.conf" + content: | + [Service] + Environment=PGDATA={{ postgresql_data_dir }} + mode: '0644' + notify: Restart postgresql + +# --- Initialisation (une seule fois) --- + +- name: Check if database cluster is initialized + ansible.builtin.stat: + path: "{{ postgresql_data_dir }}/PG_VERSION" + register: pg_initialized + +- name: Initialize database cluster + ansible.builtin.command: + cmd: "/usr/pgsql-{{ postgresql_version }}/bin/initdb -D {{ postgresql_data_dir }} --auth-local=peer --auth-host=scram-sha-256" + become: true + become_user: postgres + environment: + PGDATA: "{{ postgresql_data_dir }}" + when: not pg_initialized.stat.exists + +# --- Configuration --- + +- name: Deploy pg_hba.conf + ansible.builtin.template: + src: pg_hba.conf.j2 + dest: "{{ postgresql_data_dir }}/pg_hba.conf" + owner: postgres + group: postgres + mode: '0600' + notify: Reload postgresql + +- name: Configure listen_addresses + ansible.builtin.lineinfile: + path: "{{ postgresql_data_dir }}/postgresql.conf" + regexp: "^#?listen_addresses" + line: "listen_addresses = '{{ postgresql_listen_addresses }}'" + notify: Reload postgresql + +- name: Configure port + ansible.builtin.lineinfile: + path: "{{ postgresql_data_dir }}/postgresql.conf" + regexp: "^#?port" + line: "port = {{ postgresql_port }}" + notify: Reload postgresql + +- name: Configure shared_buffers + ansible.builtin.lineinfile: + path: "{{ postgresql_data_dir }}/postgresql.conf" + regexp: "^#?shared_buffers" + line: "shared_buffers = {{ postgresql_shared_buffers }}" + notify: Reload postgresql + +# --- Démarrage --- + +- name: Enable and start PostgreSQL + ansible.builtin.systemd: + name: "postgresql-{{ postgresql_version }}" + enabled: true + daemon_reload: true + state: started + +# --- Bases de données et utilisateurs --- + +- name: Create databases + community.postgresql.postgresql_db: + name: "{{ item.name }}" + encoding: UTF8 + state: present + become: true + become_user: postgres + loop: "{{ postgresql_databases }}" + +- name: Create users + community.postgresql.postgresql_user: + name: "{{ item.name }}" + password: "{{ item.password }}" + state: present + become: true + become_user: postgres + loop: "{{ postgresql_users }}" + no_log: true + +- name: Grant user privileges on their database + community.postgresql.postgresql_privs: + db: "{{ item.db }}" + privs: ALL + type: database + role: "{{ item.name }}" + state: present + become: true + become_user: postgres + loop: "{{ postgresql_users }}" + +# Firewall géré centralement par le rôle gateway (nftables) +# Port 5432 ouvert pour cluster_network dans nftables.conf.j2 diff --git a/ansible/roles/postgresql/templates/pg_hba.conf.j2 b/ansible/roles/postgresql/templates/pg_hba.conf.j2 new file mode 100644 index 0000000..316b177 --- /dev/null +++ b/ansible/roles/postgresql/templates/pg_hba.conf.j2 @@ -0,0 +1,9 @@ +# TYPE DATABASE USER ADDRESS METHOD +# Accès local superuser +local all postgres peer +# Accès local apps — scram-sha-256 +local all all scram-sha-256 +host all all 127.0.0.1/32 scram-sha-256 +host all all ::1/128 scram-sha-256 +# Accès depuis le LAN cluster (services k8s futurs) +host all all {{ cluster_network }} scram-sha-256 diff --git a/ansible/roles/qdrant/defaults/main.yml b/ansible/roles/qdrant/defaults/main.yml new file mode 100644 index 0000000..d24dad6 --- /dev/null +++ b/ansible/roles/qdrant/defaults/main.yml @@ -0,0 +1,13 @@ +--- +qdrant_version: "1.13.4" +qdrant_user: qdrant +qdrant_data_dir: /srv/data/qdrant +qdrant_install_dir: /opt/qdrant + +qdrant_http_port: 6333 +qdrant_grpc_port: 6334 + +# 0.0.0.0 — accessible depuis le LAN cluster (firewall filtre) +qdrant_host: "0.0.0.0" + +qdrant_log_level: INFO diff --git a/ansible/roles/qdrant/handlers/main.yml b/ansible/roles/qdrant/handlers/main.yml new file mode 100644 index 0000000..88ff3e1 --- /dev/null +++ b/ansible/roles/qdrant/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart qdrant + ansible.builtin.systemd: + name: qdrant + state: restarted diff --git a/ansible/roles/qdrant/tasks/main.yml b/ansible/roles/qdrant/tasks/main.yml new file mode 100644 index 0000000..6cc36be --- /dev/null +++ b/ansible/roles/qdrant/tasks/main.yml @@ -0,0 +1,92 @@ +--- +# --- Utilisateur système --- + +- name: Create qdrant system user + ansible.builtin.user: + name: "{{ qdrant_user }}" + system: true + shell: /sbin/nologin + home: "{{ qdrant_install_dir }}" + create_home: true + state: present + +# --- Répertoires données (RAID5) --- + +- name: Create Qdrant data directories on RAID5 + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ qdrant_user }}" + group: "{{ qdrant_user }}" + mode: '0750' + loop: + - "{{ qdrant_data_dir }}" + - "{{ qdrant_data_dir }}/storage" + - "{{ qdrant_data_dir }}/snapshots" + +# --- Binaire --- + +- name: Check if qdrant binary is already installed + ansible.builtin.stat: + path: "{{ qdrant_install_dir }}/qdrant" + register: qdrant_binary + +- name: Check installed qdrant version + ansible.builtin.command: + cmd: "{{ qdrant_install_dir }}/qdrant --version" + register: qdrant_current_version + changed_when: false + failed_when: false + when: qdrant_binary.stat.exists + +- name: Download qdrant binary + ansible.builtin.get_url: + url: "https://github.com/qdrant/qdrant/releases/download/v{{ qdrant_version }}/qdrant-x86_64-unknown-linux-musl.tar.gz" + dest: /tmp/qdrant.tar.gz + mode: '0644' + when: > + not qdrant_binary.stat.exists or + qdrant_version not in (qdrant_current_version.stdout | default('')) + +- name: Extract qdrant binary + ansible.builtin.unarchive: + src: /tmp/qdrant.tar.gz + dest: "{{ qdrant_install_dir }}" + remote_src: true + owner: "{{ qdrant_user }}" + group: "{{ qdrant_user }}" + when: > + not qdrant_binary.stat.exists or + qdrant_version not in (qdrant_current_version.stdout | default('')) + notify: Restart qdrant + +# --- Configuration --- + +- name: Deploy Qdrant config + ansible.builtin.template: + src: config.yaml.j2 + dest: "{{ qdrant_install_dir }}/config.yaml" + owner: "{{ qdrant_user }}" + group: "{{ qdrant_user }}" + mode: '0640' + notify: Restart qdrant + +# --- Service systemd --- + +- name: Deploy Qdrant systemd service + ansible.builtin.template: + src: qdrant.service.j2 + dest: /etc/systemd/system/qdrant.service + mode: '0644' + notify: + - Restart qdrant + +- name: Enable and start Qdrant + ansible.builtin.systemd: + name: qdrant + enabled: true + daemon_reload: true + state: started + +# Firewall géré centralement par le rôle gateway (nftables) +# Ports 6333 (HTTP) et 6334 (gRPC) ouverts pour cluster_network dans nftables.conf.j2 diff --git a/ansible/roles/qdrant/templates/config.yaml.j2 b/ansible/roles/qdrant/templates/config.yaml.j2 new file mode 100644 index 0000000..f642b94 --- /dev/null +++ b/ansible/roles/qdrant/templates/config.yaml.j2 @@ -0,0 +1,11 @@ +storage: + storage_path: {{ qdrant_data_dir }}/storage + snapshots_path: {{ qdrant_data_dir }}/snapshots + +service: + host: {{ qdrant_host }} + http_port: {{ qdrant_http_port }} + grpc_port: {{ qdrant_grpc_port }} + enable_cors: true + +log_level: {{ qdrant_log_level }} diff --git a/ansible/roles/qdrant/templates/qdrant.service.j2 b/ansible/roles/qdrant/templates/qdrant.service.j2 new file mode 100644 index 0000000..ca48be1 --- /dev/null +++ b/ansible/roles/qdrant/templates/qdrant.service.j2 @@ -0,0 +1,18 @@ +[Unit] +Description=Qdrant — vector database +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User={{ qdrant_user }} +Group={{ qdrant_user }} +ExecStart={{ qdrant_install_dir }}/qdrant --config-path {{ qdrant_install_dir }}/config.yaml +Restart=on-failure +RestartSec=10 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=qdrant + +[Install] +WantedBy=multi-user.target diff --git a/ansible/roles/rocm/defaults/main.yml b/ansible/roles/rocm/defaults/main.yml new file mode 100644 index 0000000..62a87c6 --- /dev/null +++ b/ansible/roles/rocm/defaults/main.yml @@ -0,0 +1,25 @@ +--- +rocm_rhel_version: "9.4" +rocm_amdgpu_repo: "https://repo.radeon.com/amdgpu/latest/rhel/{{ rocm_rhel_version }}/main/x86_64/" +rocm_repo: "https://repo.radeon.com/rocm/rhel9/latest/main/" +rocm_gpg_key: "https://repo.radeon.com/rocm/rocm.gpg.key" + +rocm_packages: + - rocminfo + - rocm-hip-runtime + - rocm-opencl-runtime + - rocblas + - hipblaslt + +# Packages supplémentaires requis pour compiler llama.cpp from source (rôle llama_server) +rocm_build_packages: + - hipblas-devel + - rocblas-devel + - hip-devel + - hipcc + - cmake + - gcc-c++ + - git + - patchelf + +hsa_override_gfx_version: "10.3.0" diff --git a/ansible/roles/rocm/handlers/main.yml b/ansible/roles/rocm/handlers/main.yml new file mode 100644 index 0000000..aa16ab7 --- /dev/null +++ b/ansible/roles/rocm/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: Reload amdgpu module + ansible.builtin.shell: | + modprobe -r amdgpu || true + modprobe amdgpu + changed_when: true diff --git a/ansible/roles/rocm/tasks/main.yml b/ansible/roles/rocm/tasks/main.yml new file mode 100644 index 0000000..6f37a51 --- /dev/null +++ b/ansible/roles/rocm/tasks/main.yml @@ -0,0 +1,60 @@ +--- +- name: Import AMD GPG key + ansible.builtin.rpm_key: + key: "{{ rocm_gpg_key }}" + state: present + +- name: Add amdgpu repository + ansible.builtin.yum_repository: + name: amdgpu + description: AMD GPU drivers + baseurl: "{{ rocm_amdgpu_repo }}" + enabled: true + gpgcheck: true + gpgkey: "{{ rocm_gpg_key }}" + +- name: Add ROCm repository + ansible.builtin.yum_repository: + name: rocm + description: ROCm + baseurl: "{{ rocm_repo }}" + enabled: true + gpgcheck: true + gpgkey: "{{ rocm_gpg_key }}" + +- name: Install kernel headers and devel (requis pour amdgpu-dkms) + ansible.builtin.dnf: + name: + - kernel-headers + - kernel-devel + state: present + +- name: Install ROCm packages + ansible.builtin.dnf: + name: "{{ rocm_packages }}" + state: present + notify: Reload amdgpu module + +- name: Add ansible user to video and render groups + ansible.builtin.user: + name: "{{ ansible_service_user }}" + groups: + - video + - render + append: true + +- name: Set HSA_OVERRIDE_GFX_VERSION system-wide + ansible.builtin.lineinfile: + path: /etc/environment + regexp: '^HSA_OVERRIDE_GFX_VERSION=' + line: "HSA_OVERRIDE_GFX_VERSION={{ hsa_override_gfx_version }}" + create: true + mode: '0644' + +- name: Set HSA_OVERRIDE_GFX_VERSION dans profile.d (sessions interactives) + ansible.builtin.copy: + dest: /etc/profile.d/rocm.sh + content: | + export HSA_OVERRIDE_GFX_VERSION={{ hsa_override_gfx_version }} + export PATH=$PATH:/opt/rocm/bin + mode: '0644' diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..556e1ae --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +ansible-core>=2.17,<2.18