ajoute la recherche plein texte dans les cours et labs (bloc C)
Elasticsearch interne (jamais exposé, sécurité x-pack désactivée, cohérent avec le reste des services internes de ce serveur), tas JVM fixé à 512 Mo plutôt que le calcul automatique (jusqu'à 50 % de la RAM système, désastreux sur ce serveur à 31 Go réels). Appels HTTP directs depuis Laravel, pas de bibliothèque cliente pour un besoin aussi simple. Index reconstruit après chaque synchronisation du catalogue, page de recherche commune aux profs et aux élèves. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
e549f03e1b
commit
777dd1cbc8
20 changed files with 545 additions and 0 deletions
11
ansible/elasticsearch.yml
Normal file
11
ansible/elasticsearch.yml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
---
|
||||||
|
# Bloc C : recherche plein texte dans les cours et labs.
|
||||||
|
# Rejoue aussi `portail` : c'est lui qui connaît {{ portail_dossier_app }}
|
||||||
|
# pour écrire ELASTICSEARCH_HOTE/PORT dans son .env.
|
||||||
|
- name: Portail et Elasticsearch
|
||||||
|
hosts: syllabus
|
||||||
|
become: true
|
||||||
|
gather_facts: true
|
||||||
|
roles:
|
||||||
|
- portail
|
||||||
|
- elasticsearch
|
||||||
7
ansible/roles/elasticsearch/defaults/main.yml
Normal file
7
ansible/roles/elasticsearch/defaults/main.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
---
|
||||||
|
elasticsearch_port: 9200
|
||||||
|
# Catalogue de cours/labs seulement, volume très faible : un tas aussi
|
||||||
|
# grand que le calcul automatique d'Elasticsearch (jusqu'à 50 % de la RAM
|
||||||
|
# système) serait désastreux sur ce serveur à 31 Go partagés avec
|
||||||
|
# OpenStack et le portail. Voir docs/runbooks/elasticsearch.md.
|
||||||
|
elasticsearch_tas_mo: 512
|
||||||
6
ansible/roles/elasticsearch/handlers/main.yml
Normal file
6
ansible/roles/elasticsearch/handlers/main.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
- name: redémarrer elasticsearch
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: elasticsearch
|
||||||
|
state: restarted
|
||||||
|
enabled: true
|
||||||
63
ansible/roles/elasticsearch/tasks/main.yml
Normal file
63
ansible/roles/elasticsearch/tasks/main.yml
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
---
|
||||||
|
# Bloc C (CLAUDE.md) : recherche plein texte dans les cours et labs.
|
||||||
|
# Instance interne seule (voir elasticsearch.yml.j2) : jamais exposée,
|
||||||
|
# jamais de réseau à configurer dans Nginx — seul le portail lui parle, en
|
||||||
|
# PHP, sur 127.0.0.1.
|
||||||
|
- name: ajouter la clé de signature du dépôt Elastic
|
||||||
|
ansible.builtin.get_url:
|
||||||
|
url: https://artifacts.elastic.co/GPG-KEY-elasticsearch
|
||||||
|
dest: /tmp/elastic-gpg-key.asc
|
||||||
|
mode: "0644"
|
||||||
|
register: elasticsearch_cle_brute
|
||||||
|
|
||||||
|
- name: déposer la clé au format attendu par apt
|
||||||
|
ansible.builtin.shell: gpg --dearmor < /tmp/elastic-gpg-key.asc > /usr/share/keyrings/elastic.gpg
|
||||||
|
args:
|
||||||
|
creates: /usr/share/keyrings/elastic.gpg
|
||||||
|
|
||||||
|
- name: ajouter le dépôt Elastic 8.x
|
||||||
|
ansible.builtin.apt_repository:
|
||||||
|
repo: "deb [signed-by=/usr/share/keyrings/elastic.gpg] https://artifacts.elastic.co/packages/8.x/apt stable main"
|
||||||
|
filename: elastic-8.x
|
||||||
|
state: present
|
||||||
|
update_cache: true
|
||||||
|
|
||||||
|
- name: installer Elasticsearch
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: elasticsearch
|
||||||
|
state: present
|
||||||
|
register: elasticsearch_installation
|
||||||
|
|
||||||
|
- name: déployer la configuration (sécurité désactivée, écoute locale seulement)
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: elasticsearch.yml.j2
|
||||||
|
dest: /etc/elasticsearch/elasticsearch.yml
|
||||||
|
owner: root
|
||||||
|
group: elasticsearch
|
||||||
|
mode: "0660"
|
||||||
|
notify: redémarrer elasticsearch
|
||||||
|
|
||||||
|
- name: réduire le tas JVM (catalogue de cours/labs, volume très faible)
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: heap.options.j2
|
||||||
|
dest: /etc/elasticsearch/jvm.options.d/heap.options
|
||||||
|
owner: root
|
||||||
|
group: elasticsearch
|
||||||
|
mode: "0660"
|
||||||
|
notify: redémarrer elasticsearch
|
||||||
|
|
||||||
|
- name: activer et démarrer Elasticsearch
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: elasticsearch
|
||||||
|
state: started
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- name: ajouter/mettre à jour les variables Elasticsearch dans .env du portail
|
||||||
|
ansible.builtin.lineinfile:
|
||||||
|
path: "{{ portail_dossier_app }}/.env"
|
||||||
|
regexp: "^{{ item.cle }}="
|
||||||
|
line: "{{ item.cle }}={{ item.valeur }}"
|
||||||
|
loop:
|
||||||
|
- { cle: "ELASTICSEARCH_HOTE", valeur: "127.0.0.1" }
|
||||||
|
- { cle: "ELASTICSEARCH_PORT", valeur: "{{ elasticsearch_port }}" }
|
||||||
|
notify: redémarrer horizon
|
||||||
15
ansible/roles/elasticsearch/templates/elasticsearch.yml.j2
Normal file
15
ansible/roles/elasticsearch/templates/elasticsearch.yml.j2
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Géré par Ansible (rôle elasticsearch) — ne pas modifier à la main.
|
||||||
|
# Instance interne (consommée seulement par le portail, jamais exposée) :
|
||||||
|
# sécurité x-pack désactivée, comme les autres services internes de ce
|
||||||
|
# serveur (MariaDB/Redis de l'app sur 127.0.0.1, API OpenStack sur
|
||||||
|
# 10.10.0.1 en HTTP) — l'isolation vient du pare-feu et de l'écoute en
|
||||||
|
# local seulement, pas de TLS/authentification par service. Voir ADR 0009.
|
||||||
|
path.data: /var/lib/elasticsearch
|
||||||
|
path.logs: /var/log/elasticsearch
|
||||||
|
discovery.type: single-node
|
||||||
|
network.host: 127.0.0.1
|
||||||
|
http.port: {{ elasticsearch_port }}
|
||||||
|
xpack.security.enabled: false
|
||||||
|
xpack.security.enrollment.enabled: false
|
||||||
|
xpack.security.http.ssl.enabled: false
|
||||||
|
xpack.security.transport.ssl.enabled: false
|
||||||
3
ansible/roles/elasticsearch/templates/heap.options.j2
Normal file
3
ansible/roles/elasticsearch/templates/heap.options.j2
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Géré par Ansible (rôle elasticsearch) — ne pas modifier à la main.
|
||||||
|
-Xms{{ elasticsearch_tas_mo }}m
|
||||||
|
-Xmx{{ elasticsearch_tas_mo }}m
|
||||||
70
docs/adr/0009-elasticsearch-interne-sans-securite.md
Normal file
70
docs/adr/0009-elasticsearch-interne-sans-securite.md
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# ADR 0009 — Elasticsearch interne, sécurité x-pack désactivée
|
||||||
|
|
||||||
|
- **Date** : 2026-10-14
|
||||||
|
- **Statut** : accepté
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Bloc C du CLAUDE.md : « ElasticSearch : recherche plein texte dans les cours
|
||||||
|
et les labs (seulement si le besoin est réel). » Adam a confirmé le besoin
|
||||||
|
explicitement (« fonce »). Catalogue minuscule (quelques labs, un seul
|
||||||
|
`cours.md` aujourd'hui) : aucun besoin d'un cluster, d'un cache de
|
||||||
|
répertoires exotique ou d'une bibliothèque cliente PHP dédiée.
|
||||||
|
|
||||||
|
## Décisions
|
||||||
|
|
||||||
|
**Une instance Elasticsearch 8.19, locale, jamais exposée.** Installée
|
||||||
|
depuis le dépôt APT officiel Elastic (clé GPG vérifiée, même principe que
|
||||||
|
Ceph/ADR 0007). Consommée uniquement par le portail, en PHP, sur
|
||||||
|
`127.0.0.1:9200` — jamais par un navigateur.
|
||||||
|
|
||||||
|
**Sécurité x-pack désactivée (`xpack.security.enabled: false`).**
|
||||||
|
L'installation standard active TLS + authentification par défaut depuis la
|
||||||
|
8.x (mot de passe généré, certificats auto-signés) — pertinent pour un
|
||||||
|
cluster multi-nœuds exposé, disproportionné pour une instance mono-nœud qui
|
||||||
|
ne parle qu'à elle-même sur la boucle locale. Cohérent avec le reste de ce
|
||||||
|
serveur : l'API OpenStack interne (`10.10.0.1`) et le MariaDB/Redis dédiés
|
||||||
|
au portail (`127.0.0.1:3307`/`6380`) n'ont pas non plus de TLS entre
|
||||||
|
services — l'isolation vient du pare-feu (`policy drop` par défaut) et de
|
||||||
|
l'écoute en local, pas d'une authentification par service. Désactiver x-pack
|
||||||
|
évite aussi de devoir stocker et faire tourner un mot de passe Elasticsearch
|
||||||
|
supplémentaire pour rien.
|
||||||
|
|
||||||
|
**Tas JVM réduit à 512 Mo (`-Xms512m -Xmx512m`), pas le calcul automatique
|
||||||
|
d'Elasticsearch.** Par défaut, Elasticsearch vise jusqu'à 50 % de la RAM
|
||||||
|
système — sur ce serveur à 31 Go réels (voir ADR 0007), cela aurait pu
|
||||||
|
réserver ~15 Go à un service qui indexe quelques dizaines de documents.
|
||||||
|
Mesuré après coup : ~900 Mo de RSS au total pour le processus, RAM
|
||||||
|
disponible du serveur restée stable (~6 Go) pendant tout le chantier.
|
||||||
|
|
||||||
|
**Appels HTTP directs (`Illuminate\Support\Facades\Http`), pas le client
|
||||||
|
PHP officiel `elastic/elasticsearch`.** Le besoin se limite à indexer des
|
||||||
|
documents et faire une recherche plein texte avec surlignage — deux appels
|
||||||
|
REST simples. Ajouter une bibliothèque cliente complète (et ses
|
||||||
|
dépendances) pour ça aurait été une techno sans besoin réel (CLAUDE.md,
|
||||||
|
section 8).
|
||||||
|
|
||||||
|
**Analyseur `french` d'Elasticsearch sur les champs `titre`/`contenu`.**
|
||||||
|
Livré avec Elasticsearch, pas un plugin à installer : améliore réellement la
|
||||||
|
pertinence sur du contenu pédagogique en français (élision, racines des
|
||||||
|
mots) sans coût d'exploitation supplémentaire.
|
||||||
|
|
||||||
|
**Reconstruction complète de l'index à chaque synchronisation**
|
||||||
|
(`labs:synchroniser`, `cours:synchroniser`), pas une mise à jour
|
||||||
|
incrémentale. Volume du catalogue trop faible pour que ça coûte quoi que ce
|
||||||
|
soit ; plus simple et plus sûr (jamais de document orphelin si un lab est
|
||||||
|
retiré).
|
||||||
|
|
||||||
|
## Conséquences
|
||||||
|
|
||||||
|
- Recherche vérifiée en réel avec un compte élève jetable : les deux vrais
|
||||||
|
documents existants (labs catalogués + `08-deux-lan-routeur/cours.md`,
|
||||||
|
contenu réel) retrouvés par recherche plein texte, extrait surligné
|
||||||
|
affiché, compte supprimé ensuite.
|
||||||
|
- `App\Services\Recherche\IndexRecherche::disponible()` renvoie `false` si
|
||||||
|
`ELASTICSEARCH_HOTE` n'est pas renseigné : la page de recherche affiche
|
||||||
|
alors un message d'indisponibilité au lieu d'une erreur — même principe
|
||||||
|
que `StockageTravauxEleves::disponible()` pour le S3.
|
||||||
|
- En dette : pas de pagination des résultats (taille de catalogue bien en
|
||||||
|
dessous du besoin), pas de recherche à facettes (niveau, compétence) —
|
||||||
|
à ajouter si le catalogue grossit réellement.
|
||||||
|
|
@ -40,3 +40,4 @@ retirer la ligne (et le noter dans le journal) quand c'est fait.
|
||||||
| Ceph : démo mono-serveur seulement (1 OSD sur fichier épars, pas de réplication, pas branché à Cinder/Glance) | `ceph_pool_default_size=1` et un seul OSD acceptés uniquement parce que c'est une démo de compréhension, sans donnée réelle. Le vrai cluster (plusieurs OSD, réplication, intégré à Cinder) attend le 2ᵉ serveur du CLAUDE.md. Voir [ADR 0007](adr/0007-ceph-demo-mono-serveur.md). | 2ᵉ serveur (bloc C) | 2026-10-14 |
|
| Ceph : démo mono-serveur seulement (1 OSD sur fichier épars, pas de réplication, pas branché à Cinder/Glance) | `ceph_pool_default_size=1` et un seul OSD acceptés uniquement parce que c'est une démo de compréhension, sans donnée réelle. Le vrai cluster (plusieurs OSD, réplication, intégré à Cinder) attend le 2ᵉ serveur du CLAUDE.md. Voir [ADR 0007](adr/0007-ceph-demo-mono-serveur.md). | 2ᵉ serveur (bloc C) | 2026-10-14 |
|
||||||
| Ceph : bootstrap fait à la main, pas encore rejouable par Ansible | Comme Kolla à l'étape 2 : acceptable pour une démo, à automatiser si ce cluster doit durer. Le fichier systemd du loop device est suivi dans `infra/ceph/` mais déployé à la main. | Si la démo devient durable | 2026-10-14 |
|
| Ceph : bootstrap fait à la main, pas encore rejouable par Ansible | Comme Kolla à l'étape 2 : acceptable pour une démo, à automatiser si ce cluster doit durer. Le fichier systemd du loop device est suivi dans `infra/ceph/` mais déployé à la main. | Si la démo devient durable | 2026-10-14 |
|
||||||
| WebDAV de l'élève : stockage local, pas S3 ; pas de quota par élève | Dossier local (`storage/app/webdav/`), partage le disque serré avec OpenStack et le portail — pas de limite de taille par élève. rclone sait servir un backend S3 sans changement d'architecture (voir [ADR 0008](adr/0008-webdav-eleve-auth-request.md)) : à basculer en même temps que le bucket S3 des devoirs (`docs/runbooks/stockage-s3.md`), et à ajouter un quota si l'usage réel le demande. | Quand le bucket S3 existe / si l'usage le demande | 2026-10-14 |
|
| WebDAV de l'élève : stockage local, pas S3 ; pas de quota par élève | Dossier local (`storage/app/webdav/`), partage le disque serré avec OpenStack et le portail — pas de limite de taille par élève. rclone sait servir un backend S3 sans changement d'architecture (voir [ADR 0008](adr/0008-webdav-eleve-auth-request.md)) : à basculer en même temps que le bucket S3 des devoirs (`docs/runbooks/stockage-s3.md`), et à ajouter un quota si l'usage réel le demande. | Quand le bucket S3 existe / si l'usage le demande | 2026-10-14 |
|
||||||
|
| Recherche (Elasticsearch) : pas de pagination ni de facettes | Catalogue minuscule aujourd'hui (quelques labs, un seul vrai `cours.md`) : inutile tant que ça reste le cas. Voir [ADR 0009](adr/0009-elasticsearch-interne-sans-securite.md). | Si le catalogue grossit réellement | 2026-10-14 |
|
||||||
|
|
|
||||||
|
|
@ -1251,3 +1251,26 @@ de haut en bas — une bonne question à savoir expliquer en entretien SRE.
|
||||||
**Suite**
|
**Suite**
|
||||||
|
|
||||||
Prochain chantier du bloc C : ElasticSearch.
|
Prochain chantier du bloc C : ElasticSearch.
|
||||||
|
|
||||||
|
## 2026-10-14 — Recherche plein texte, Elasticsearch (bloc C)
|
||||||
|
|
||||||
|
Troisième chantier du bloc C : recherche plein texte dans les cours et les
|
||||||
|
labs. Détail des choix : [ADR 0009](adr/0009-elasticsearch-interne-sans-securite.md).
|
||||||
|
|
||||||
|
**Fait**
|
||||||
|
|
||||||
|
- Elasticsearch 8.19 installé depuis le dépôt officiel (clé GPG vérifiée), instance strictement interne : écoute `127.0.0.1` seulement, sécurité x-pack désactivée (cohérent avec le reste du serveur — l'API OpenStack interne et le MariaDB/Redis du portail n'ont pas de TLS entre services non plus, l'isolation vient du pare-feu), tas JVM limité à 512 Mo (le calcul automatique d'Elasticsearch, jusqu'à 50 % de la RAM système, aurait pu réserver ~15 Go sur ce serveur à 31 Go réels).
|
||||||
|
- `App\Services\Recherche\IndexRecherche` : appels HTTP directs (pas de bibliothèque cliente, le besoin se limite à indexer et chercher), analyseur `french` d'Elasticsearch sur les champs texte, `disponible()` même principe que le stockage S3 (la page affiche juste « pas disponible » tant que ce n'est pas configuré, jamais une erreur).
|
||||||
|
- Index reconstruit automatiquement après `labs:synchroniser` et `cours:synchroniser` — pas de mise à jour incrémentale, le catalogue est trop petit pour que ça coûte quoi que ce soit.
|
||||||
|
- Page « Recherche » (menu du compte, profs et élèves), route commune aux deux guards (`auth:eleve,web`, déjà utilisée par « Mes cours »/« Paramètres »).
|
||||||
|
- 5 tests Pest (`Http::fake()`, jamais contre un vrai Elasticsearch) : indisponibilité propre, accès par les deux guards, surlignage affiché avec le bon lien vers la page du cours, reconstruction de l'index envoie bien un document par lab/cours, et n'envoie *aucun* appel réseau quand Elasticsearch n'est pas configuré.
|
||||||
|
|
||||||
|
**Vérifié en réel** : index reconstruit sur le serveur depuis les vraies données (2 documents : le lab 08 et son vrai `cours.md`), recherche du mot « routage » retrouve les deux avec le bon score, page `/recherche` testée avec un compte élève jetable (connexion réelle par cookies de session, pas juste un appel direct au contrôleur) — le mot recherché apparaît bien surligné (`<em>routage</em>`) et le lien vers le cours fonctionne. RAM disponible du serveur restée stable (~6 Go) pendant tout le chantier. Compte de test supprimé ensuite.
|
||||||
|
|
||||||
|
**Comprendre**
|
||||||
|
|
||||||
|
Le réflexe acquis avec Ceph et WebDAV (vérifier l'état réel avant d'agir, mesurer plutôt que deviner) a évité ici une vraie bêtise : Elasticsearch calcule par défaut un tas JVM jusqu'à 50 % de la RAM système, ce qui aurait été catastrophique sur ce serveur sans même un message d'erreur — juste un service qui prend toute la RAM disponible. Fixer explicitement le tas avant le premier démarrage, plutôt qu'après avoir constaté le problème, est le genre de précaution qui ne coûte rien et qui évite un incident.
|
||||||
|
|
||||||
|
**Suite**
|
||||||
|
|
||||||
|
Bloc C restant : WebDAV et Ceph faits, Elasticsearch fait. ElasticSearch avait été écarté du MCP développeurs par Adam (pas pour l'instant). Prochains chantiers confirmés par Adam : LTI Moodle 1.3, puis SSO ENT (Keycloak) — normalement des étapes V2/V3 du produit, avancées à la demande explicite d'Adam.
|
||||||
|
|
|
||||||
64
docs/runbooks/elasticsearch.md
Normal file
64
docs/runbooks/elasticsearch.md
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# Runbook — Elasticsearch (recherche dans les cours et labs)
|
||||||
|
|
||||||
|
Instance interne, jamais exposée, consommée seulement par le portail en PHP
|
||||||
|
(`App\Services\Recherche\IndexRecherche`). Détail des choix :
|
||||||
|
[ADR 0009](../adr/0009-elasticsearch-interne-sans-securite.md).
|
||||||
|
|
||||||
|
## Où sont les choses
|
||||||
|
|
||||||
|
| Élément | Emplacement |
|
||||||
|
|---|---|
|
||||||
|
| Config | `/etc/elasticsearch/elasticsearch.yml` (sécurité x-pack désactivée, écoute `127.0.0.1` seulement) |
|
||||||
|
| Tas JVM | `/etc/elasticsearch/jvm.options.d/heap.options` (512 Mo, voir ADR 0009) |
|
||||||
|
| Index | `paillasse_catalogue` |
|
||||||
|
| Déployé par | `ansible/elasticsearch.yml` (rôle `elasticsearch`, rejoue aussi `portail` pour écrire `.env`) |
|
||||||
|
|
||||||
|
```
|
||||||
|
cd ansible && script -q /dev/null ansible-playbook elasticsearch.yml -i inventory.ini
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vérifier que ça fonctionne
|
||||||
|
|
||||||
|
```
|
||||||
|
systemctl is-active elasticsearch
|
||||||
|
curl -s http://127.0.0.1:9200/paillasse_catalogue/_count
|
||||||
|
```
|
||||||
|
|
||||||
|
Reconstruire l'index manuellement (après un changement de mapping, ou si le
|
||||||
|
catalogue semble désynchronisé) :
|
||||||
|
|
||||||
|
```
|
||||||
|
cd /var/www/portail
|
||||||
|
sudo -u www-data HOME=/tmp php8.3 artisan recherche:indexer
|
||||||
|
```
|
||||||
|
|
||||||
|
Autrement, l'index se reconstruit automatiquement après `labs:synchroniser`
|
||||||
|
et `cours:synchroniser` (voir ces deux commandes).
|
||||||
|
|
||||||
|
## Pourquoi la RAM est surveillée
|
||||||
|
|
||||||
|
Comme pour Ceph et WebDAV (voir les runbooks correspondants) : ce serveur a
|
||||||
|
réellement 31 Go de RAM, partagés avec OpenStack et le portail. Le tas JVM
|
||||||
|
est volontairement limité à 512 Mo (~900 Mo de RSS au total mesuré) — ne
|
||||||
|
jamais retirer `heap.options` ni laisser Elasticsearch calculer sa taille de
|
||||||
|
tas automatiquement sur ce serveur.
|
||||||
|
|
||||||
|
## Si le besoin grossit réellement
|
||||||
|
|
||||||
|
Pas de pagination ni de recherche à facettes pour l'instant (catalogue
|
||||||
|
minuscule). À ajouter dans `App\Services\Recherche\IndexRecherche` et
|
||||||
|
`RechercheController` si le catalogue de cours/labs grossit vraiment —
|
||||||
|
jamais avant, pour ne pas construire une fonction sans usage réel.
|
||||||
|
|
||||||
|
## Nettoyer entièrement (si besoin)
|
||||||
|
|
||||||
|
```
|
||||||
|
ssh paillasse
|
||||||
|
sudo systemctl disable --now elasticsearch
|
||||||
|
sudo rm -rf /var/lib/elasticsearch /etc/elasticsearch
|
||||||
|
sudo apt-get purge -y elasticsearch
|
||||||
|
sudo rm /etc/apt/sources.list.d/elastic-8.x.list /usr/share/keyrings/elastic.gpg
|
||||||
|
sudo apt-get update
|
||||||
|
```
|
||||||
|
Puis retirer `ELASTICSEARCH_HOTE`/`ELASTICSEARCH_PORT` du `.env` du portail
|
||||||
|
et redémarrer Horizon.
|
||||||
30
portail/app/Console/Commands/IndexerRecherche.php
Normal file
30
portail/app/Console/Commands/IndexerRecherche.php
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Recherche\IndexRecherche;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejoue l'index de recherche (bloc C) depuis les tables labs/cours —
|
||||||
|
* appelé après labs:synchroniser et cours:synchroniser, et disponible ici
|
||||||
|
* pour une reconstruction manuelle (changement de mapping, par exemple).
|
||||||
|
*/
|
||||||
|
class IndexerRecherche extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'recherche:indexer';
|
||||||
|
|
||||||
|
protected $description = 'Reconstruit l’index de recherche (cours et labs) dans Elasticsearch';
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
if (! IndexRecherche::disponible()) {
|
||||||
|
$this->warn('Elasticsearch non configuré : rien à indexer.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IndexRecherche::reconstruire();
|
||||||
|
$this->info('Index reconstruit.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\Cours;
|
use App\Models\Cours;
|
||||||
use App\Services\Catalogue\CacheCatalogue;
|
use App\Services\Catalogue\CacheCatalogue;
|
||||||
|
use App\Services\Recherche\IndexRecherche;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
@ -44,5 +45,6 @@ class SynchroniserCours extends Command
|
||||||
}
|
}
|
||||||
|
|
||||||
CacheCatalogue::viderCours();
|
CacheCatalogue::viderCours();
|
||||||
|
IndexRecherche::reconstruire();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ namespace App\Console\Commands;
|
||||||
use App\Models\Lab;
|
use App\Models\Lab;
|
||||||
use App\Services\Catalogue\CacheCatalogue;
|
use App\Services\Catalogue\CacheCatalogue;
|
||||||
use App\Services\Labs\DefinitionLab;
|
use App\Services\Labs\DefinitionLab;
|
||||||
|
use App\Services\Recherche\IndexRecherche;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -32,5 +33,6 @@ class SynchroniserLabs extends Command
|
||||||
}
|
}
|
||||||
|
|
||||||
CacheCatalogue::viderLabs();
|
CacheCatalogue::viderLabs();
|
||||||
|
IndexRecherche::reconstruire();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
portail/app/Http/Controllers/RechercheController.php
Normal file
25
portail/app/Http/Controllers/RechercheController.php
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Services\Recherche\IndexRecherche;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche plein texte dans les cours et labs (bloc C, CLAUDE.md), commune
|
||||||
|
* aux profs et aux élèves — pas de donnée sensible dans le catalogue.
|
||||||
|
*/
|
||||||
|
class RechercheController extends Controller
|
||||||
|
{
|
||||||
|
public function chercher(Request $request): View
|
||||||
|
{
|
||||||
|
$texte = trim((string) $request->query('q', ''));
|
||||||
|
|
||||||
|
return view('recherche', [
|
||||||
|
'texte' => $texte,
|
||||||
|
'resultats' => IndexRecherche::chercher($texte),
|
||||||
|
'disponible' => IndexRecherche::disponible(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
portail/app/Services/Recherche/IndexRecherche.php
Normal file
101
portail/app/Services/Recherche/IndexRecherche.php
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Recherche;
|
||||||
|
|
||||||
|
use App\Models\Cours;
|
||||||
|
use App\Models\Lab;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bloc C (CLAUDE.md) : recherche plein texte dans les cours et labs.
|
||||||
|
* Instance Elasticsearch interne (voir ansible/roles/elasticsearch), appels
|
||||||
|
* HTTP directs plutôt qu'une bibliothèque cliente : le besoin se limite à
|
||||||
|
* indexer et chercher, pas de dépendance ajoutée pour ça.
|
||||||
|
*/
|
||||||
|
class IndexRecherche
|
||||||
|
{
|
||||||
|
public static function disponible(): bool
|
||||||
|
{
|
||||||
|
return filled(config('elasticsearch.hote'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reconstruire(): void
|
||||||
|
{
|
||||||
|
if (! self::disponible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Http::delete(self::url());
|
||||||
|
Http::put(self::url(), [
|
||||||
|
'mappings' => [
|
||||||
|
'properties' => [
|
||||||
|
'identifiant' => ['type' => 'keyword'],
|
||||||
|
'type' => ['type' => 'keyword'],
|
||||||
|
'titre' => ['type' => 'text', 'analyzer' => 'french'],
|
||||||
|
'contenu' => ['type' => 'text', 'analyzer' => 'french'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$documents = Lab::all()
|
||||||
|
->map(fn (Lab $lab) => [
|
||||||
|
'identifiant' => $lab->identifiant,
|
||||||
|
'type' => 'lab',
|
||||||
|
'titre' => $lab->titre,
|
||||||
|
'contenu' => trim($lab->titre.' '.implode(' ', $lab->competences ?? [])),
|
||||||
|
])
|
||||||
|
->concat(Cours::all()->map(fn (Cours $cours) => [
|
||||||
|
'identifiant' => $cours->identifiant,
|
||||||
|
'type' => 'cours',
|
||||||
|
'titre' => $cours->titre,
|
||||||
|
'contenu' => trim($cours->titre.' '.self::texteBrut($cours->contenu_markdown)),
|
||||||
|
]));
|
||||||
|
|
||||||
|
foreach ($documents as $document) {
|
||||||
|
Http::put(self::url("/_doc/{$document['type']}-{$document['identifiant']}"), $document);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{type: string, identifiant: string, titre: string, extrait: string}>
|
||||||
|
*/
|
||||||
|
public static function chercher(string $texte): array
|
||||||
|
{
|
||||||
|
if (! self::disponible() || trim($texte) === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$reponse = Http::post(self::url('/_search'), [
|
||||||
|
'query' => ['multi_match' => ['query' => $texte, 'fields' => ['titre^3', 'contenu']]],
|
||||||
|
'highlight' => ['fields' => ['contenu' => new \stdClass]],
|
||||||
|
'size' => 20,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($reponse->failed()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<int, array<string, mixed>> $resultats */
|
||||||
|
$resultats = $reponse->json('hits.hits') ?? [];
|
||||||
|
|
||||||
|
return collect($resultats)
|
||||||
|
->map(fn (array $hit) => [
|
||||||
|
'type' => $hit['_source']['type'],
|
||||||
|
'identifiant' => $hit['_source']['identifiant'],
|
||||||
|
'titre' => $hit['_source']['titre'],
|
||||||
|
'extrait' => $hit['highlight']['contenu'][0] ?? Str::limit($hit['_source']['contenu'], 200),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function url(string $chemin = ''): string
|
||||||
|
{
|
||||||
|
return 'http://'.config('elasticsearch.hote').':'.config('elasticsearch.port').'/'.config('elasticsearch.index').$chemin;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function texteBrut(string $markdown): string
|
||||||
|
{
|
||||||
|
return trim(preg_replace('/[#*`_\[\]()>-]/', ' ', $markdown) ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
10
portail/config/elasticsearch.php
Normal file
10
portail/config/elasticsearch.php
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
// Bloc C (CLAUDE.md) : recherche plein texte dans les cours et labs,
|
||||||
|
// instance interne (voir ansible/roles/elasticsearch), jamais de
|
||||||
|
// dépendance ajoutée : appels HTTP directs (App\Services\Recherche\IndexRecherche).
|
||||||
|
'hote' => env('ELASTICSEARCH_HOTE'),
|
||||||
|
'port' => env('ELASTICSEARCH_PORT', 9200),
|
||||||
|
'index' => 'paillasse_catalogue',
|
||||||
|
];
|
||||||
|
|
@ -37,6 +37,10 @@
|
||||||
Mes fichiers
|
Mes fichiers
|
||||||
</a>
|
</a>
|
||||||
@endif
|
@endif
|
||||||
|
<a href="{{ route('recherche') }}" role="menuitem">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="10.5" cy="10.5" r="6.5"/><path d="M15.5 15.5 21 21"/></svg>
|
||||||
|
Recherche
|
||||||
|
</a>
|
||||||
<a href="{{ route('aide.app') }}" role="menuitem">
|
<a href="{{ route('aide.app') }}" role="menuitem">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.8.4-1 .8-1 1.7v.5"/><path d="M12 17h.01"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.8.4-1 .8-1 1.7v.5"/><path d="M12 17h.01"/></svg>
|
||||||
Aide et assistance
|
Aide et assistance
|
||||||
|
|
|
||||||
37
portail/resources/views/recherche.blade.php
Normal file
37
portail/resources/views/recherche.blade.php
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('titre', 'Recherche')
|
||||||
|
|
||||||
|
@section('contenu')
|
||||||
|
<div class="mx-auto max-w-3xl px-5 py-10">
|
||||||
|
<h1 class="text-3xl font-extrabold" style="color:var(--sy-ink)">Recherche</h1>
|
||||||
|
<p class="mt-2 font-semibold" style="color:var(--sy-muted)">Dans les cours et les labs du catalogue.</p>
|
||||||
|
|
||||||
|
<form action="{{ route('recherche') }}" method="get" class="mt-6 flex gap-2">
|
||||||
|
<input type="search" name="q" value="{{ $texte }}" placeholder="Adressage IPv4, routage, chiffrement…" class="flex-1 rounded-xl border border-[#c4c7c5] bg-white px-4 py-2 font-semibold" autofocus>
|
||||||
|
<button type="submit" class="inline-flex h-11 items-center rounded-full bg-black px-5 font-bold text-white">Chercher</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@if (! $disponible)
|
||||||
|
<p class="mt-6 rounded-2xl border p-4 font-semibold" style="border-color:var(--sy-line);background:var(--sy-surface);color:var(--sy-muted)">
|
||||||
|
La recherche n'est pas disponible pour le moment.
|
||||||
|
</p>
|
||||||
|
@elseif ($texte !== '' && empty($resultats))
|
||||||
|
<p class="mt-6 font-semibold" style="color:var(--sy-muted)">Rien trouvé pour « {{ $texte }} ».</p>
|
||||||
|
@elseif (! empty($resultats))
|
||||||
|
<ul class="mt-6 grid gap-3">
|
||||||
|
@foreach ($resultats as $resultat)
|
||||||
|
<li class="rounded-2xl border p-5" style="border-color:var(--sy-line);background:var(--sy-surface)">
|
||||||
|
<p class="font-mono text-xs font-semibold uppercase" style="color:var(--sy-muted)">{{ $resultat['type'] === 'cours' ? 'Cours' : 'Lab' }}</p>
|
||||||
|
@if ($resultat['type'] === 'cours')
|
||||||
|
<a href="{{ route('mes-cours.show', $resultat['identifiant']) }}" class="mt-1 block text-lg font-extrabold underline underline-offset-4" style="color:var(--sy-ink)">{{ $resultat['titre'] }}</a>
|
||||||
|
@else
|
||||||
|
<p class="mt-1 text-lg font-extrabold" style="color:var(--sy-ink)">{{ $resultat['titre'] }}</p>
|
||||||
|
@endif
|
||||||
|
<p class="mt-2 font-semibold" style="color:var(--sy-muted)">{!! $resultat['extrait'] !!}</p>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
|
@ -29,6 +29,7 @@ use App\Http\Controllers\QuizController;
|
||||||
use App\Http\Controllers\QuizEleveController;
|
use App\Http\Controllers\QuizEleveController;
|
||||||
use App\Http\Controllers\RaccourciController;
|
use App\Http\Controllers\RaccourciController;
|
||||||
use App\Http\Controllers\RapportCspController;
|
use App\Http\Controllers\RapportCspController;
|
||||||
|
use App\Http\Controllers\RechercheController;
|
||||||
use App\Http\Controllers\RejoindreClasseController;
|
use App\Http\Controllers\RejoindreClasseController;
|
||||||
use App\Http\Controllers\ResultatsController;
|
use App\Http\Controllers\ResultatsController;
|
||||||
use App\Http\Controllers\SeanceController;
|
use App\Http\Controllers\SeanceController;
|
||||||
|
|
@ -122,6 +123,7 @@ Route::middleware(['auth:eleve,web', 'session.unique:eleve', 'session.unique:web
|
||||||
Route::post('/raccourcis/{raccourci}/supprimer', [RaccourciController::class, 'destroy'])->name('raccourcis.destroy');
|
Route::post('/raccourcis/{raccourci}/supprimer', [RaccourciController::class, 'destroy'])->name('raccourcis.destroy');
|
||||||
Route::get('/emploi-du-temps', [EmploiDuTempsController::class, 'show'])->name('emploi-du-temps');
|
Route::get('/emploi-du-temps', [EmploiDuTempsController::class, 'show'])->name('emploi-du-temps');
|
||||||
Route::get('/mes-cours/{cours:identifiant}', [MesCoursController::class, 'montrer'])->name('mes-cours.show');
|
Route::get('/mes-cours/{cours:identifiant}', [MesCoursController::class, 'montrer'])->name('mes-cours.show');
|
||||||
|
Route::get('/recherche', [RechercheController::class, 'chercher'])->middleware('throttle:30,1')->name('recherche');
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Espace élève (protégé, session unique) ---
|
// --- Espace élève (protégé, session unique) ---
|
||||||
|
|
|
||||||
69
portail/tests/Feature/RechercheTest.php
Normal file
69
portail/tests/Feature/RechercheTest.php
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Classe;
|
||||||
|
use App\Models\Cours;
|
||||||
|
use App\Models\Eleve;
|
||||||
|
use App\Models\Lab;
|
||||||
|
use App\Services\Recherche\IndexRecherche;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
test('la recherche affiche un message d’indisponibilité tant qu’Elasticsearch n’est pas configuré', function () {
|
||||||
|
$eleve = Eleve::factory()->create();
|
||||||
|
|
||||||
|
eleve($this, $eleve)->get(route('recherche', ['q' => 'routage']))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('pas disponible');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un prof et un élève peuvent tous les deux utiliser la recherche', function () {
|
||||||
|
$eleve = Eleve::factory()->create();
|
||||||
|
eleve($this, $eleve)->get(route('recherche'))->assertOk();
|
||||||
|
|
||||||
|
$classe = Classe::factory()->create();
|
||||||
|
commeProf($this, $classe->prof)->get(route('recherche'))->assertOk();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un résultat de type cours renvoie vers la page du cours, avec l’extrait surligné', function () {
|
||||||
|
config(['elasticsearch.hote' => '127.0.0.1']);
|
||||||
|
Cours::create(['identifiant' => '08-deux-lan-routeur', 'titre' => 'Deux LAN et un routeur', 'contenu_markdown' => 'Le routage statique...']);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'*/_search' => Http::response([
|
||||||
|
'hits' => ['hits' => [
|
||||||
|
[
|
||||||
|
'_source' => ['type' => 'cours', 'identifiant' => '08-deux-lan-routeur', 'titre' => 'Deux LAN et un routeur', 'contenu' => 'Le routage statique...'],
|
||||||
|
'highlight' => ['contenu' => ['Le <em>routage</em> statique...']],
|
||||||
|
],
|
||||||
|
]],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$eleve = Eleve::factory()->create();
|
||||||
|
|
||||||
|
eleve($this, $eleve)->get(route('recherche', ['q' => 'routage']))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Deux LAN et un routeur')
|
||||||
|
->assertSee(route('mes-cours.show', '08-deux-lan-routeur'), false)
|
||||||
|
->assertSee('<em>routage</em>', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reconstruire l’index envoie un document par lab et par cours', function () {
|
||||||
|
config(['elasticsearch.hote' => '127.0.0.1']);
|
||||||
|
Http::fake();
|
||||||
|
Lab::factory()->create(['identifiant' => 'lab-test', 'titre' => 'Lab de test', 'competences' => ['routage']]);
|
||||||
|
Cours::create(['identifiant' => 'cours-test', 'titre' => 'Cours de test', 'contenu_markdown' => '# Titre']);
|
||||||
|
|
||||||
|
IndexRecherche::reconstruire();
|
||||||
|
|
||||||
|
Http::assertSent(fn ($r) => $r->method() === 'DELETE' && str_ends_with($r->url(), '/paillasse_catalogue'));
|
||||||
|
Http::assertSent(fn ($r) => $r->method() === 'PUT' && str_ends_with($r->url(), '/_doc/lab-lab-test'));
|
||||||
|
Http::assertSent(fn ($r) => $r->method() === 'PUT' && str_ends_with($r->url(), '/_doc/cours-cours-test'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sans Elasticsearch configuré, reconstruire l’index ne fait aucun appel réseau', function () {
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
IndexRecherche::reconstruire();
|
||||||
|
|
||||||
|
Http::assertNothingSent();
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue