Mise à jour vers 0.8.5 ; ajout de la bascule entre le canal bêta et le canal stable
Some checks failed
E2E Tests / e2e (push) Failing after 21s
Build & Push Images / build (brain) (push) Successful in 1m0s
Build & Push Images / build (core) (push) Successful in 1m34s
Build & Push Images / build-switcher (push) Successful in 39s
Build & Push Images / build (web) (push) Successful in 1m40s
Some checks failed
E2E Tests / e2e (push) Failing after 21s
Build & Push Images / build (brain) (push) Successful in 1m0s
Build & Push Images / build (core) (push) Successful in 1m34s
Build & Push Images / build-switcher (push) Successful in 39s
Build & Push Images / build (web) (push) Successful in 1m40s
This commit is contained in:
26
switcher/Dockerfile
Normal file
26
switcher/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
# LoreMind channel switcher — sidecar minimal qui orchestre les bascules
|
||||
# stable <-> beta. Tourne en permanence en attente d'une commande deposee
|
||||
# dans le volume partage par le Core.
|
||||
#
|
||||
# Image volontairement legere (Alpine + docker-cli + bash). Pas de port
|
||||
# expose, pas de processus reseau : tout passe par fichiers + socket Docker.
|
||||
FROM alpine:3.20
|
||||
|
||||
# docker-cli : pour parler au socket Docker du host
|
||||
# docker-cli-compose : pour `docker compose pull/up`
|
||||
# bash : pour les scripts (sh ne suffit pas, on utilise des features bash)
|
||||
# jq : parsing JSON de la commande
|
||||
# coreutils : pour `date -u --iso-8601=seconds`
|
||||
RUN apk add --no-cache \
|
||||
docker-cli \
|
||||
docker-cli-compose \
|
||||
bash \
|
||||
jq \
|
||||
coreutils
|
||||
|
||||
WORKDIR /switcher
|
||||
COPY watch.sh switch.sh ./
|
||||
RUN chmod +x watch.sh switch.sh
|
||||
|
||||
# Tourne en permanence en mode polling.
|
||||
ENTRYPOINT ["/switcher/watch.sh"]
|
||||
66
switcher/README.md
Normal file
66
switcher/README.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# LoreMind channel switcher
|
||||
|
||||
Sidecar qui bascule LoreMind entre les canaux **stable** et **beta** depuis l'UI,
|
||||
sans manipulation manuelle du `.env` ni de docker-compose.
|
||||
|
||||
## Principe
|
||||
|
||||
Le switcher est un container minimal (Alpine + docker-cli + bash) qui :
|
||||
|
||||
1. Watch un fichier `command.json` dans un volume partagé avec le Core
|
||||
2. Quand une commande arrive :
|
||||
- Valide le canal cible (`stable` | `beta`)
|
||||
- Sed la ligne `IMAGE_NAMESPACE` du `.env` du host
|
||||
- Lance `docker compose pull` puis `docker compose up -d` sur core/brain/web
|
||||
3. Écrit son résultat dans `result.json` (le Core remonte ça à l'UI via polling)
|
||||
|
||||
## Sécurité
|
||||
|
||||
Le switcher a accès au socket Docker et au répertoire compose du host (RW),
|
||||
donc beaucoup de pouvoir. Pour éviter qu'une compromission du Core devienne
|
||||
un RCE sur l'hôte :
|
||||
|
||||
- Le Core n'a **pas** accès au socket Docker — il dépose une commande dans un
|
||||
fichier, point.
|
||||
- Le switcher **valide strictement** le contenu : `channel` doit valoir exactement
|
||||
`stable` ou `beta` (case statement, pas de regex laxiste).
|
||||
- Aucun port n'est exposé. La communication se fait uniquement via volume
|
||||
partagé.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐ ┌────────────┐
|
||||
│ User clique │ │ Core │ │ switcher │ │ Docker │
|
||||
│ "Passer beta"│─▶│ écrit command.json│─▶│ sed .env │─▶│ daemon │
|
||||
│ dans UI │ │ dans volume │ │ docker compose│ │ (recreate) │
|
||||
└──────────────┘ └──────────────────┘ └──────────────┘ └────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ result.json │ ◄── Core poll
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
## Upgrade pour les installs existantes
|
||||
|
||||
Le sidecar est arrivé dans LoreMind 0.9.0. Pour les installs antérieures qui
|
||||
ne l'ont pas dans leur `docker-compose.yml`, l'utilisateur doit faire une
|
||||
**dernière** manipulation :
|
||||
|
||||
1. Récupérer le nouveau `docker-compose.yml` du repo
|
||||
2. Lancer `docker compose pull && docker compose up -d`
|
||||
|
||||
Après ça, tous les switchs futurs se font depuis l'UI sans intervention CLI.
|
||||
|
||||
## Pourquoi le switcher n'est PAS dans `IMAGE_NAMESPACE`
|
||||
|
||||
L'image du switcher est codée en dur (`ghcr.io/igmlcreation/loremind-switcher`)
|
||||
plutôt que d'utiliser `${IMAGE_NAMESPACE}`. Raison : pendant un switch, le
|
||||
switcher exécute `docker compose up -d`. Si son propre image faisait partie
|
||||
de `IMAGE_NAMESPACE`, le compose voudrait le recréer en même temps que
|
||||
core/brain/web — et il se tuerait au milieu de sa propre commande. Race
|
||||
condition fatale.
|
||||
|
||||
Pour la même raison, le `docker compose up -d` dans `switch.sh` cible
|
||||
explicitement `core brain web --no-deps` — jamais le switcher lui-même.
|
||||
112
switcher/switch.sh
Normal file
112
switcher/switch.sh
Normal file
@@ -0,0 +1,112 @@
|
||||
#!/bin/bash
|
||||
# switch.sh — execute le switch de canal pour LoreMind.
|
||||
#
|
||||
# Usage interne (appele par watch.sh) :
|
||||
# ./switch.sh stable
|
||||
# ./switch.sh beta
|
||||
#
|
||||
# Ce que ca fait, dans l'ordre :
|
||||
# 1. Valide l'argument (stable|beta uniquement, rien d'autre — defense
|
||||
# contre command injection si le Core etait compromis)
|
||||
# 2. Sed la ligne IMAGE_NAMESPACE= du .env du host pour basculer le prefixe
|
||||
# 3. docker compose pull (recupere les nouvelles images du canal cible)
|
||||
# 4. docker compose up -d (recree core/brain/web avec les nouvelles images)
|
||||
#
|
||||
# Le switcher LUI-MEME n'est PAS dans IMAGE_NAMESPACE — il survit au switch
|
||||
# sans interruption (cf. docker-compose.yml).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CHANNEL="${1:-}"
|
||||
|
||||
# --- Validation stricte -----------------------------------------------------
|
||||
# Aucune autre valeur acceptee. Pas d'echappement, pas de slash, rien.
|
||||
# C'est le filet de securite si le JSON depose dans /data/command.json
|
||||
# contenait un payload exotique (Core compromis = on ne laisse PAS
|
||||
# executer du code arbitraire sur l'hote).
|
||||
case "${CHANNEL}" in
|
||||
stable|beta) ;;
|
||||
*)
|
||||
echo "Channel invalide: '${CHANNEL}' (attendu: stable|beta)" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
# --- Configuration ---------------------------------------------------------
|
||||
# Repertoire monte depuis l'hote contenant docker-compose.yml + .env
|
||||
COMPOSE_DIR="${COMPOSE_DIR:-/compose}"
|
||||
ENV_FILE="${COMPOSE_DIR}/.env"
|
||||
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
echo "Fichier .env introuvable dans ${COMPOSE_DIR}" >&2
|
||||
exit 3
|
||||
fi
|
||||
if [[ ! -f "${COMPOSE_DIR}/docker-compose.yml" ]]; then
|
||||
echo "docker-compose.yml introuvable dans ${COMPOSE_DIR}" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# --- Detection du nom de projet compose ------------------------------------
|
||||
# Pour eviter que le switcher cree un projet PARALLELE (cas ou COMPOSE_PROJECT_NAME
|
||||
# ne correspond pas au nom du projet sous lequel les containers tournent),
|
||||
# on lit le label compose du container core en cours d'execution.
|
||||
# Ce label est ecrit par docker compose au moment du `up -d` initial — c'est
|
||||
# la source de verite.
|
||||
PROJECT_NAME=$(docker inspect loremind-core \
|
||||
--format '{{ index .Config.Labels "com.docker.compose.project" }}' \
|
||||
2>/dev/null || echo "")
|
||||
if [[ -z "${PROJECT_NAME}" ]]; then
|
||||
# Fallback : env var ou defaut. Ne devrait pas arriver en prod
|
||||
# (loremind-core tourne forcement quand l'UI declenche un switch).
|
||||
PROJECT_NAME="${COMPOSE_PROJECT_NAME:-loremind}"
|
||||
echo "Warning: nom de projet auto-detecte impossible, fallback sur '${PROJECT_NAME}'" >&2
|
||||
fi
|
||||
export COMPOSE_PROJECT_NAME="${PROJECT_NAME}"
|
||||
echo "→ Projet compose cible: ${PROJECT_NAME}"
|
||||
|
||||
# --- Mapping canal -> namespace --------------------------------------------
|
||||
# Le slash final est important : il est concatene avec le suffixe image
|
||||
# (core/brain/web) dans le docker-compose.yml.
|
||||
case "${CHANNEL}" in
|
||||
stable) NAMESPACE="igmlcreation/loremind-" ;;
|
||||
beta) NAMESPACE="igmlcreation/loremind-beta-" ;;
|
||||
esac
|
||||
|
||||
# --- Etape 1 : sed le .env -------------------------------------------------
|
||||
# On veut REMPLACER une ligne existante IMAGE_NAMESPACE=... ou AJOUTER
|
||||
# si absente. Cas typique : .env utilisateur peut avoir cette ligne ou non.
|
||||
#
|
||||
# Sed -i avec un pattern qui matche la ligne entiere. Si pas de match,
|
||||
# on append.
|
||||
echo "→ Mise a jour de IMAGE_NAMESPACE dans .env (canal: ${CHANNEL})"
|
||||
if grep -q '^IMAGE_NAMESPACE=' "${ENV_FILE}"; then
|
||||
# Sur Alpine, sed -i sans backup. Le pattern d'echappement '/' dans
|
||||
# le namespace impose un delimiter alternatif (|).
|
||||
sed -i "s|^IMAGE_NAMESPACE=.*|IMAGE_NAMESPACE=${NAMESPACE}|" "${ENV_FILE}"
|
||||
else
|
||||
# Ligne absente → on l'ajoute en fin de fichier avec un commentaire.
|
||||
{
|
||||
echo ""
|
||||
echo "# Ajoute automatiquement par le switcher de canal LoreMind."
|
||||
echo "IMAGE_NAMESPACE=${NAMESPACE}"
|
||||
} >> "${ENV_FILE}"
|
||||
fi
|
||||
|
||||
# --- Etape 2 : docker compose pull -----------------------------------------
|
||||
echo "→ Pull des nouvelles images (${NAMESPACE}*)"
|
||||
# --no-deps inutile ici : pull n'a pas de notion de deps.
|
||||
# --policy missing eviterait de re-puller si deja la, mais on VEUT puller
|
||||
# pour avoir la derniere version disponible — c'est le but du switch.
|
||||
cd "${COMPOSE_DIR}"
|
||||
docker compose pull core brain web
|
||||
|
||||
# --- Etape 3 : recreate les containers avec les nouvelles images -----------
|
||||
# On cible explicitement core/brain/web — pas le switcher (qui s'auto-tuerait
|
||||
# au milieu de la commande), pas postgres/minio (pas de changement d'image).
|
||||
# --no-deps : ne pas re-recreer postgres/minio comme effet de bord.
|
||||
echo "→ Recreation des containers avec les nouvelles images"
|
||||
docker compose up -d --no-deps core brain web
|
||||
|
||||
echo ""
|
||||
echo "Switch vers le canal ${CHANNEL} termine avec succes."
|
||||
echo "Containers core/brain/web recrees avec ${NAMESPACE}*."
|
||||
84
switcher/watch.sh
Normal file
84
switcher/watch.sh
Normal file
@@ -0,0 +1,84 @@
|
||||
#!/bin/bash
|
||||
# watch.sh — boucle principale du switcher.
|
||||
#
|
||||
# Surveille /data/command.json (depose par le Core via l'API HTTP) et lance
|
||||
# switch.sh quand une nouvelle commande arrive. L'ID de la commande sert
|
||||
# d'idempotence : on ne traite pas deux fois la meme requete.
|
||||
#
|
||||
# Le resultat est ecrit dans /data/result.json pour que le Core puisse le
|
||||
# remonter a l'UI via son endpoint de status.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DATA_DIR="/data"
|
||||
COMMAND_FILE="${DATA_DIR}/command.json"
|
||||
RESULT_FILE="${DATA_DIR}/result.json"
|
||||
LAST_PROCESSED_FILE="${DATA_DIR}/.last-processed-id"
|
||||
|
||||
mkdir -p "${DATA_DIR}"
|
||||
|
||||
log() {
|
||||
echo "[$(date -u --iso-8601=seconds)] $*"
|
||||
}
|
||||
|
||||
# Ecrit un resultat JSON dans result.json — atomique via tmp + mv.
|
||||
write_result() {
|
||||
local status="$1" # "in-progress" | "success" | "error"
|
||||
local channel="$2" # "stable" | "beta" | ""
|
||||
local message="$3"
|
||||
local id="$4"
|
||||
|
||||
local tmp
|
||||
tmp="$(mktemp -p "${DATA_DIR}" result.XXXXXX)"
|
||||
cat > "${tmp}" <<EOF
|
||||
{
|
||||
"id": "${id}",
|
||||
"status": "${status}",
|
||||
"channel": "${channel}",
|
||||
"message": $(printf '%s' "${message}" | jq -Rs .),
|
||||
"completedAt": "$(date -u --iso-8601=seconds)"
|
||||
}
|
||||
EOF
|
||||
mv "${tmp}" "${RESULT_FILE}"
|
||||
}
|
||||
|
||||
log "LoreMind channel switcher started — watching ${COMMAND_FILE}"
|
||||
|
||||
# Boucle de polling. Intervalle court (1s) — la charge est negligeable
|
||||
# (un test de fichier) et l'utilisateur attend une reaction rapide.
|
||||
while true; do
|
||||
if [[ -f "${COMMAND_FILE}" ]]; then
|
||||
# Parse la commande. Tolere les JSON malformes : on ignore et on attend.
|
||||
if ! id=$(jq -er '.id' "${COMMAND_FILE}" 2>/dev/null); then
|
||||
sleep 1
|
||||
continue
|
||||
fi
|
||||
|
||||
# Idempotence : skip si on a deja traite cet ID.
|
||||
last_id=""
|
||||
[[ -f "${LAST_PROCESSED_FILE}" ]] && last_id=$(cat "${LAST_PROCESSED_FILE}")
|
||||
if [[ "${id}" == "${last_id}" ]]; then
|
||||
sleep 1
|
||||
continue
|
||||
fi
|
||||
|
||||
channel=$(jq -er '.channel' "${COMMAND_FILE}" 2>/dev/null || echo "")
|
||||
|
||||
log "New command received: id=${id} channel=${channel}"
|
||||
write_result "in-progress" "${channel}" "Switch en cours..." "${id}"
|
||||
|
||||
# Lance le switch. On capture stdout+stderr et le code de sortie.
|
||||
if output=$(/switcher/switch.sh "${channel}" 2>&1); then
|
||||
log "Switch SUCCESS for id=${id} channel=${channel}"
|
||||
write_result "success" "${channel}" "${output}" "${id}"
|
||||
else
|
||||
rc=$?
|
||||
log "Switch FAILED for id=${id} channel=${channel} rc=${rc}"
|
||||
write_result "error" "${channel}" "${output}" "${id}"
|
||||
fi
|
||||
|
||||
# Marque l'ID comme traite — empeche les replays.
|
||||
echo "${id}" > "${LAST_PROCESSED_FILE}"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
Reference in New Issue
Block a user