agent v0.3.8: fix crash UI notifications, auto Podman machine DNS, WordPress 7.0.0 ready template
This commit is contained in:
@@ -27,4 +27,5 @@ headscale/*.state
|
|||||||
agent/resolv.conf
|
agent/resolv.conf
|
||||||
agent/tailscale-bin/
|
agent/tailscale-bin/
|
||||||
agent/studioE5-agent-test
|
agent/studioE5-agent-test
|
||||||
|
agent/.cache-go/
|
||||||
server/tsconfig.tsbuildinfo
|
server/tsconfig.tsbuildinfo
|
||||||
|
|||||||
+238
-2
@@ -64,6 +64,68 @@ HTTP/2 200
|
|||||||
- Certificat Let’s Encrypt obtenu automatiquement par Caddy (`tls { on_demand }`).
|
- Certificat Let’s Encrypt obtenu automatiquement par Caddy (`tls { on_demand }`).
|
||||||
- Le resolver réécrit les en-têtes `Location` et le contenu HTML pour passer de `http://` à `https://`.
|
- Le resolver réécrit les en-têtes `Location` et le contenu HTML pour passer de `http://` à `https://`.
|
||||||
|
|
||||||
|
## 📝 Template WordPress prêt à l’emploi
|
||||||
|
|
||||||
|
Un nouveau template `wordpress-ready-wordpress-latest` a été créé et validé le 2026-06-26. Il fournit un WordPress déjà initialisé en français, prêt à l’usage en classe ou en examen.
|
||||||
|
|
||||||
|
### Contenu du template
|
||||||
|
|
||||||
|
| Élément | Valeur / État |
|
||||||
|
|---|---|
|
||||||
|
| Langue | **Français** (`fr_FR`) |
|
||||||
|
| Titre du site | **Mon site wordpress** |
|
||||||
|
| Compte administrateur | **admin / admin** |
|
||||||
|
| Thème actif | **Astra** |
|
||||||
|
| Spectra | installé et **actif** |
|
||||||
|
| Yoast SEO | installé mais **inactif** |
|
||||||
|
| Mises à jour automatiques | **désactivées** (core, plugins, thèmes) |
|
||||||
|
| DNS conteneur | `8.8.8.8` + `1.1.1.1` pour permettre l’accès à `api.wordpress.org` |
|
||||||
|
|
||||||
|
### Architecture technique
|
||||||
|
|
||||||
|
- Le modèle `Template` de Prisma dispose d’un nouveau champ `initScript` (`TEXT?`).
|
||||||
|
- Le seed génère le template avec :
|
||||||
|
- une section `dns` dans le service `app` du `docker-compose.yml` ;
|
||||||
|
- un service sidecar `wp-init` (image `wordpress:cli`) exécutant le script d’initialisation.
|
||||||
|
- L’agent écrit le script `wp-init.sh` dans le dossier de l’instance au démarrage.
|
||||||
|
- Le conteneur `wp-init` attend que WordPress soit prêt, puis exécute WP-CLI en tant que `www-data`.
|
||||||
|
- Un fichier flag `.studioe5-init-done` évite de réinitialiser l’instance à chaque redémarrage.
|
||||||
|
|
||||||
|
### Fichiers modifiés / ajoutés
|
||||||
|
|
||||||
|
- `server/prisma/schema.prisma` – champ `initScript` sur `Template`.
|
||||||
|
- `server/prisma/seed.ts` – génération du template `wordpress-ready-wordpress-latest`.
|
||||||
|
- `server/templates/wordpress-ready/wp-init.sh` – script d’initialisation WP-CLI.
|
||||||
|
- `server/app/api/instances/route.ts` – envoi de `initScript` à l’agent avec remplacement des placeholders.
|
||||||
|
- `agent/websocket.go` – réception et transmission de `InitScript`.
|
||||||
|
- `agent/docker.go` – écriture du script dans le dossier instance (`writeInitScript`).
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
Instance de test créée via l’API (`cmqv03a6v0001vg8zrpe8zqfy`) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ curl -sS -I -L https://cmqv03a6v0001vg8zrpe8zqfy.studioe5.edudeploy.com/
|
||||||
|
HTTP/2 200
|
||||||
|
```
|
||||||
|
|
||||||
|
- Page d’accueil en français, titre **« Mon site wordpress »**.
|
||||||
|
- Connexion admin `/wp-login.php` avec **admin / admin** fonctionnelle.
|
||||||
|
- Tableau de bord en français.
|
||||||
|
- Plugins : Spectra actif, Yoast SEO inactif.
|
||||||
|
- `wp-config.php` contient les constantes de désactivation des mises à jour automatiques.
|
||||||
|
|
||||||
|
Les instances de test ont été nettoyées après validation.
|
||||||
|
|
||||||
|
### Template versionné WordPress 7.0.0
|
||||||
|
|
||||||
|
Un second template `wordpress-ready-wordpress-7.0.0-php8.3` a été ajouté pour figer WordPress sur la version **7.0.0** (PHP 8.3) au lieu de `latest`. Cela garantit que tous les postes déploient exactement la même version, sans dépendre du cache local de `wordpress:latest`.
|
||||||
|
|
||||||
|
| Template | Image Docker |
|
||||||
|
|---|---|
|
||||||
|
| `wordpress-ready-wordpress-latest` | `wordpress:latest` |
|
||||||
|
| `wordpress-ready-wordpress-7.0.0-php8.3` | `wordpress:7.0.0-php8.3` |
|
||||||
|
|
||||||
## 📁 Fichiers modifiés (non exhaustif)
|
## 📁 Fichiers modifiés (non exhaustif)
|
||||||
|
|
||||||
- `agent/tailscale.go` – lancement `tailscaled` + `tailscale up`, gestion start/stop/status.
|
- `agent/tailscale.go` – lancement `tailscaled` + `tailscale up`, gestion start/stop/status.
|
||||||
@@ -79,7 +141,7 @@ Agent de test lancé en arrière-plan :
|
|||||||
- data-dir : `/tmp/studioe5-test-clienta`
|
- data-dir : `/tmp/studioe5-test-clienta`
|
||||||
- node-id : `vps-8fc665eb`
|
- node-id : `vps-8fc665eb`
|
||||||
- tailnet IP actuelle : `100.64.0.8`
|
- tailnet IP actuelle : `100.64.0.8`
|
||||||
- PID : `3151830` (lancé le 2026-06-23 09:36 UTC)
|
- PID : voir `/tmp/studioe5-test-clienta/agent.pid` (relancé le 2026-06-26 11:53 UTC avec l’agent v0.3.5 corrigé)
|
||||||
|
|
||||||
Instance de test créée :
|
Instance de test créée :
|
||||||
- ID : `test-wp-001`
|
- ID : `test-wp-001`
|
||||||
@@ -165,6 +227,127 @@ L’agent redémarre `tailscaled` automatiquement au lancement, même si la clé
|
|||||||
- **Windows (exe)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5.exe`
|
- **Windows (exe)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5.exe`
|
||||||
- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5`
|
- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.5`
|
||||||
|
|
||||||
|
## 🪟 Agent v0.3.6 – recover() dans les goroutines de démarrage d’instance
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
|
||||||
|
Lors de la création d’une instance depuis le dashboard vers certains agents (notamment Windows), l’agent s’arrêtait brutalement. Le `recover()` présent dans `handleMessage` ne capturait pas le panic car celui-ci survenait dans les goroutines lancées par `go handleStartInstance(...)`.
|
||||||
|
|
||||||
|
### Corrections apportées
|
||||||
|
|
||||||
|
- Ajout d’un `defer recover()` dans `handleStartInstance` ; en cas de panic, l’instance passe en statut `error` et un message `instance_error` est envoyé au serveur.
|
||||||
|
- Ajout d’un `defer recover()` dans toutes les goroutines critiques du WebSocket :
|
||||||
|
- `start_vpn`
|
||||||
|
- `stop_vpn`
|
||||||
|
- `start`
|
||||||
|
- `reset`
|
||||||
|
- `startTailscaleAndReport`
|
||||||
|
- cleanup au shutdown
|
||||||
|
- Ajout de logs de traçage au début de `handleStartInstance` (`instance`, `type`, `port`, `dataDir`, `initScriptLen`).
|
||||||
|
|
||||||
|
### Téléchargement
|
||||||
|
|
||||||
|
- **Windows (archive)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.6-windows.zip`
|
||||||
|
- **Windows (exe)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.6.exe`
|
||||||
|
- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.6`
|
||||||
|
|
||||||
|
### Redeploiement
|
||||||
|
|
||||||
|
- Agent rebuildé en v0.3.6 pour Windows et Linux.
|
||||||
|
- Binaires versionnés copiés dans `server/public/`.
|
||||||
|
- Page `/dashboard/download` mise à jour vers la v0.3.6.
|
||||||
|
- Serveur rebuildé et redémarré.
|
||||||
|
|
||||||
|
## 🪟 Agent v0.3.7 – recover() dans les notifications UI
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
|
||||||
|
L’agent continuait de s’arrêter brutalement lors de la création d’une instance depuis le dashboard. Le crash survenait juste après les logs `Start instance ...` et `notifyUI: broadcasting to ...`, sans laisser de trace de panic. Cela pointait vers une panique dans les goroutines de notification UI ou dans l’écriture des logs vers les clients UI locaux.
|
||||||
|
|
||||||
|
### Corrections apportées
|
||||||
|
|
||||||
|
- Ajout d’un `defer recover()` dans `notifyUI` pour chaque goroutine de notification.
|
||||||
|
- Ajout d’un `defer recover()` dans `sendUILog` (logs diffusés aux clients UI).
|
||||||
|
- Ajout d’un `defer recover()` dans `broadcastUI` (messages diffusés aux clients UI).
|
||||||
|
|
||||||
|
### Téléchargement
|
||||||
|
|
||||||
|
- **Windows (archive)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.7-windows.zip`
|
||||||
|
- **Windows (exe)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.7.exe`
|
||||||
|
- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.7`
|
||||||
|
|
||||||
|
## 🪟 Agent v0.3.8 – DNS automatique pour Podman machine (Windows/macOS)
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
|
||||||
|
Après correction du crash, l’agent Windows avec Podman échouait au `docker compose up` avec :
|
||||||
|
```text
|
||||||
|
lookup registry-1.docker.io: Temporary failure in name resolution
|
||||||
|
```
|
||||||
|
La VM Podman machine n’avait pas de DNS fonctionnel, ce qui empêchait le téléchargement des images Docker. Le DNS des conteneurs (`dns: 8.8.8.8` dans le compose) résout le problème à l’intérieur des conteneurs, mais pas pour le pull d’images par Podman machine.
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
|
||||||
|
L’agent configure automatiquement le DNS des machines Podman en cours d’exécution au démarrage :
|
||||||
|
- Détection de Podman sur Windows/macOS.
|
||||||
|
- Liste des machines Podman (`podman machine list --format json`).
|
||||||
|
- Pour chaque machine `running`, exécution de :
|
||||||
|
```bash
|
||||||
|
podman machine ssh <name> sudo sh -c 'echo nameserver 8.8.8.8 > /etc/resolv.conf && echo nameserver 1.1.1.1 >> /etc/resolv.conf'
|
||||||
|
```
|
||||||
|
|
||||||
|
Fichier ajouté : `agent/podman.go`. Appel depuis `agent/main.go` au démarrage.
|
||||||
|
|
||||||
|
### Téléchargement
|
||||||
|
|
||||||
|
- **Windows (archive)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.8-windows.zip`
|
||||||
|
- **Windows (exe)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.8.exe`
|
||||||
|
- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.8`
|
||||||
|
|
||||||
|
## 🐛 Fix synchronisation agent / dashboard
|
||||||
|
|
||||||
|
### Problème
|
||||||
|
|
||||||
|
Le statut affiché dans le dashboard pouvait diverger de l’état réel de l’agent :
|
||||||
|
- Après un **Arrêter** lancé depuis le dashboard, l’instance restait affichée comme elle l’était avant, ou disparaissait avec perte des données.
|
||||||
|
- Après une **Suppression**, l’instance n’était pas retirée de la liste.
|
||||||
|
|
||||||
|
### Causes racines
|
||||||
|
|
||||||
|
1. **Action `stop` du dashboard envoyée comme `delete` à l’agent** (`server/app/api/instances/route.ts`).
|
||||||
|
L’agent exécutait alors `docker compose down -v` + suppression des fichiers, c’est-à-dire une suppression réelle, tout en marquant l’instance `stopped` en base.
|
||||||
|
2. **L’agent ne confirmait pas les actions serveur** (`agent/websocket.go`).
|
||||||
|
Les handlers `stop` et `delete` ne renvoyaient jamais les messages `instance_stopped` / `instance_deleted` au serveur ; seule l’UI locale le faisait.
|
||||||
|
3. **Le handler `stop` de l’agent utilisait `dockerComposeDown`** au lieu de `dockerComposeStop`, ne respectant pas le cycle de vie documenté (arrêt = conteneurs et volumes conservés).
|
||||||
|
|
||||||
|
### Corrections apportées
|
||||||
|
|
||||||
|
| Fichier | Changement |
|
||||||
|
|---------|------------|
|
||||||
|
| `server/app/api/instances/route.ts` | L’action dashboard `stop` envoie désormais `action: "stop"` à l’agent (et non plus `"delete"`). |
|
||||||
|
| `agent/websocket.go` | Le cas `stop` utilise `dockerComposeStop`, puis envoie `instance_stopped` au serveur. Le cas `delete` envoie `instance_deleted` au serveur. |
|
||||||
|
| `server/lib/websocket.ts` | Utilisation de `updateMany`/`deleteMany` pour ignorer silencieusement les messages d’instances déjà absentes/supprimées (évite les erreurs Prisma en double suppression). |
|
||||||
|
|
||||||
|
### Résultat
|
||||||
|
|
||||||
|
Le dashboard reflète désormais l’état réel après une action serveur-initiée, dès le rechargement de la page. Le cycle de vie respecte la sémantique attendue :
|
||||||
|
- **Arrêter** : `docker compose stop` → statut `stopped`.
|
||||||
|
- **Démarrer** : `docker compose up -d` → statut `running`.
|
||||||
|
- **Redémarrer** : `docker compose down -v` + recréation.
|
||||||
|
- **Supprimer** : `docker compose down -v` + suppression fichiers.
|
||||||
|
|
||||||
|
### Redeploiement effectué le 2026-06-26
|
||||||
|
|
||||||
|
- **Agent rebuildé** en v0.3.5 (`agent/studioE5-agent`, `.exe`, `.zip` et `server/public/` mis à jour).
|
||||||
|
- **Serveur rebuildé et redémarré** (`docker compose up -d --build server`) pour intégrer les corrections TypeScript.
|
||||||
|
- **Page `/dashboard/download` mise à jour** : passage à la version 0.3.5 et ajout des liens Windows (.exe, .zip) et Linux.
|
||||||
|
- **Corrections défensives agent** après signalement d’arrêt brutal lors d’actions dashboard :
|
||||||
|
- `sendMessage` exécuté de manière asynchrone (`go`) dans les handlers `stop`, `delete`, `stop_vpn` et cleanup, pour ne pas bloquer la boucle de lecture WebSocket.
|
||||||
|
- Ajout d’un `recover` dans `handleMessage` pour capturer d’éventuels panics sans tuer l’agent.
|
||||||
|
- Correction du cleanup `main.go` : modification de `inst[id].Status` (et non de la copie locale `info`).
|
||||||
|
- **Agent de test Linux relancé** (PID dans `/tmp/studioe5-test-clienta/agent.pid`).
|
||||||
|
- **Agents clients** : il faut redémarrer l’agent sur chaque poste, ou télécharger à nouveau le binaire v0.3.5 depuis le dashboard pour Windows.
|
||||||
|
|
||||||
## 🛠️ Commandes utiles pour reprendre
|
## 🛠️ Commandes utiles pour reprendre
|
||||||
|
|
||||||
### Voir l’agent de test
|
### Voir l’agent de test
|
||||||
@@ -551,6 +734,50 @@ Si la clé doit être changée :
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🖥️ Installateur agent professionnel
|
||||||
|
|
||||||
|
### Objectif
|
||||||
|
|
||||||
|
Créer un package d’installation unique et professionnel par OS, incluant l’agent studioE5, Tailscale et **Podman CLI**, afin de ne plus dépendre d’installations manuelles préalables par l’utilisateur.
|
||||||
|
|
||||||
|
### Choix des outils
|
||||||
|
|
||||||
|
| OS | Outil | Format | Justification |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Windows** | **Inno Setup** | `.exe` | Gratuit, open source, très répandu, personnalisable, exécution de scripts PowerShell/silencieux. |
|
||||||
|
| **macOS** | **`pkgbuild`** | `.pkg` | Outil natif Apple, gratuit, format professionnel pour la distribution macOS. |
|
||||||
|
| **Linux** | **Script shell** (+ `.deb`/`.rpm` optionnels) | `.sh` | Universel, détecte le package manager, simple à maintenir. |
|
||||||
|
|
||||||
|
### Contenu du package par OS
|
||||||
|
|
||||||
|
- **Windows** (Inno Setup) :
|
||||||
|
- Installer l’agent dans `C:\Program Files\studioE5-agent\`.
|
||||||
|
- Extraire Tailscale dans `C:\Program Files\studioE5-agent\tailscale-bin\windows\`.
|
||||||
|
- Installer Podman CLI via le MSI officiel en mode silencieux.
|
||||||
|
- Exécuter `podman machine init` puis `podman machine start`.
|
||||||
|
- Créer un raccourci de démarrage et/ou un service Windows.
|
||||||
|
|
||||||
|
- **macOS** (`pkgbuild`) :
|
||||||
|
- Installer l’agent dans `/Applications/studioE5-agent/`.
|
||||||
|
- Installer Podman CLI.
|
||||||
|
- Exécuter `podman machine init` puis `podman machine start`.
|
||||||
|
- Optionnellement créer un LaunchAgent pour démarrer l’agent au login.
|
||||||
|
|
||||||
|
- **Linux** (script shell) :
|
||||||
|
- Détecter le package manager (`apt`, `dnf`, `pacman`, etc.).
|
||||||
|
- Installer Podman et Podman Compose.
|
||||||
|
- Copier l’agent dans `/opt/studioe5-agent/`.
|
||||||
|
- Créer le service systemd `studioe5-agent.service`.
|
||||||
|
- Activer et démarrer le service.
|
||||||
|
|
||||||
|
### Adaptations nécessaires dans l’agent
|
||||||
|
|
||||||
|
- Détecter si Podman est utilisé et si une machine est requise (Windows/macOS).
|
||||||
|
- Vérifier au démarrage que la machine Podman est démarrée, et lancer `podman machine start` si besoin.
|
||||||
|
- Gérer proprement l’arrêt de la machine à la fermeture de l’agent (optionnel).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 📋 Prochaines étapes à faire
|
## 📋 Prochaines étapes à faire
|
||||||
|
|
||||||
### ✅ Terminé
|
### ✅ Terminé
|
||||||
@@ -568,7 +795,8 @@ Si la clé doit être changée :
|
|||||||
- [x] **Agent v0.3.5 – UI locale moderne** (dashboard, logs, progression, actions d’instance).
|
- [x] **Agent v0.3.5 – UI locale moderne** (dashboard, logs, progression, actions d’instance).
|
||||||
- [x] **Agent v0.3.5 – cycle de vie des instances** (`stop`/`start` préservent les volumes, `reset`/`delete` effacent).
|
- [x] **Agent v0.3.5 – cycle de vie des instances** (`stop`/`start` préservent les volumes, `reset`/`delete` effacent).
|
||||||
- [x] **Agent v0.3.5 – cleanup au shutdown** (arrêt propre de Tailscale et des instances, notification serveur).
|
- [x] **Agent v0.3.5 – cleanup au shutdown** (arrêt propre de Tailscale et des instances, notification serveur).
|
||||||
- [x] **Synchronisation dashboard** (messages `instance_stopped` / `instance_deleted` traités côté serveur).
|
- [x] **Synchronisation dashboard** (messages `instance_stopped` / `instance_deleted` traités côté serveur, et agent renvoyant correctement ces messages après un ordre serveur `stop`/`delete`).
|
||||||
|
- [x] **Template WordPress prêt à l’emploi** (`wordpress-ready-wordpress-latest`) : WordPress en français, titre « Mon site wordpress », compte admin/admin, thème Astra, Spectra actif, Yoast SEO inactif, mises à jour automatiques désactivées, DNS `8.8.8.8`/`1.1.1.1` pour `api.wordpress.org`.
|
||||||
|
|
||||||
### ⏳ Reste à faire
|
### ⏳ Reste à faire
|
||||||
|
|
||||||
@@ -577,6 +805,14 @@ Si la clé doit être changée :
|
|||||||
- [ ] **Nettoyer les anciens nodes/volumes Headscale** de test (nœuds `edubox`, `prof`, `invalid-*` hors ligne à supprimer).
|
- [ ] **Nettoyer les anciens nodes/volumes Headscale** de test (nœuds `edubox`, `prof`, `invalid-*` hors ligne à supprimer).
|
||||||
- [ ] **Pousser la branche** vers Gitea dès que le remote sera accessible.
|
- [ ] **Pousser la branche** vers Gitea dès que le remote sera accessible.
|
||||||
- [ ] **Documenter la procédure de mise en production** pour le client A (config agent, clés Headscale, ports, ACL, etc.).
|
- [ ] **Documenter la procédure de mise en production** pour le client A (config agent, clés Headscale, ports, ACL, etc.).
|
||||||
|
- [ ] **Installateur agent professionnel (Windows / macOS / Linux)** : créer un package d’installation unique incluant l’agent studioE5, Tailscale et **Podman CLI**. Voir la section « 🖥️ Installateur agent professionnel » ci-dessous pour le détail des outils (Inno Setup, pkgbuild, script shell) et du contenu par OS.
|
||||||
|
- [ ] **Template WordPress prêt à l’emploi (usage examen/classe)** :
|
||||||
|
- Forcer le DNS (`8.8.8.8`, `1.1.1.1`) dans le `docker-compose.yml` pour permettre l’accès à la bibliothèque de plugins/mises à jour depuis le conteneur.
|
||||||
|
- Pré-installer WordPress en **français** via WP-CLI avec le titre **“Mon site wordpress”** et le compte **admin / admin**.
|
||||||
|
- Désactiver les **mises à jour automatiques** (core, plugins, thèmes) pour figer l’environnement.
|
||||||
|
- Installer et activer le **thème Astra**.
|
||||||
|
- Installer **Yoast SEO** (inactif) et **Spectra** (actif).
|
||||||
|
- [ ] **Barre de progression basée sur les logs d’installation** : enrichir la barre de progression agent/dashboard en lisant les logs Podman/Docker (`podman logs -f`) pendant le premier démarrage d’une instance. Définir des patterns de logs par template (ex. `Installation successful` pour PrestaShop) et relayer les étapes réelles au dashboard via WebSocket.
|
||||||
- [ ] **Étude – interface de déploiement multi-clients** : outil de provisionning d’un nouveau serveur client + agent générique (option A : URL serveur déterminée à l’activation).
|
- [ ] **Étude – interface de déploiement multi-clients** : outil de provisionning d’un nouveau serveur client + agent générique (option A : URL serveur déterminée à l’activation).
|
||||||
- [ ] **Sécurité – gestion et rotation des secrets** (`INTERNAL_API_KEY`, `HEADSCALE_API_KEY`, `NEXTAUTH_SECRET`, `DATABASE_URL`).
|
- [ ] **Sécurité – gestion et rotation des secrets** (`INTERNAL_API_KEY`, `HEADSCALE_API_KEY`, `NEXTAUTH_SECRET`, `DATABASE_URL`).
|
||||||
- [ ] **Sécurité – durcissement des conteneurs** (`cap_add`, utilisateurs non-root, scans CVE).
|
- [ ] **Sécurité – durcissement des conteneurs** (`cap_add`, utilisateurs non-root, scans CVE).
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
VERSION="0.3.5"
|
VERSION="0.3.8"
|
||||||
APP_NAME="studioE5"
|
APP_NAME="studioE5"
|
||||||
BIN_NAME="studioE5-agent"
|
BIN_NAME="studioE5-agent"
|
||||||
LDFLAGS="-X main.version=${VERSION}"
|
LDFLAGS="-X main.version=${VERSION}"
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ func writeCompose(dataDir, instanceID, compose string, port int) error {
|
|||||||
return os.WriteFile(f, []byte(compose), 0644)
|
return os.WriteFile(f, []byte(compose), 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeInitScript(dataDir, instanceID, script string) error {
|
||||||
|
dir := instanceDir(dataDir, instanceID)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f := filepath.Join(dir, "wp-init.sh")
|
||||||
|
return os.WriteFile(f, []byte(script), 0755)
|
||||||
|
}
|
||||||
|
|
||||||
func configureEngineCmd(cmd *exec.Cmd, dir string) {
|
func configureEngineCmd(cmd *exec.Cmd, dir string) {
|
||||||
hideWindow(cmd)
|
hideWindow(cmd)
|
||||||
logPath := filepath.Join(dir, "compose.log")
|
logPath := filepath.Join(dir, "compose.log")
|
||||||
|
|||||||
+16
-2
@@ -62,6 +62,10 @@ func main() {
|
|||||||
|
|
||||||
log.Printf("[%s Agent] version=%s node=%s data-dir=%s server=%s", APP_NAME, version, cfg.NodeID, *dataDir, cfg.Server)
|
log.Printf("[%s Agent] version=%s node=%s data-dir=%s server=%s", APP_NAME, version, cfg.NodeID, *dataDir, cfg.Server)
|
||||||
|
|
||||||
|
// Ensure Podman machine DNS is configured on Windows/macOS so images can be
|
||||||
|
// pulled and containers can reach the internet.
|
||||||
|
ensurePodmanMachineDNS()
|
||||||
|
|
||||||
if *uiEnabled {
|
if *uiEnabled {
|
||||||
go startUI(*dataDir, cfg.NodeID, cfg.Server)
|
go startUI(*dataDir, cfg.NodeID, cfg.Server)
|
||||||
}
|
}
|
||||||
@@ -84,6 +88,11 @@ func main() {
|
|||||||
cleanupWg.Add(1)
|
cleanupWg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer cleanupWg.Done()
|
defer cleanupWg.Done()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in cleanup goroutine: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
<-shutdownCh
|
<-shutdownCh
|
||||||
log.Println("Cleaning up before exit...")
|
log.Println("Cleaning up before exit...")
|
||||||
|
|
||||||
@@ -98,8 +107,8 @@ func main() {
|
|||||||
if info.Status == "running" {
|
if info.Status == "running" {
|
||||||
log.Printf("Stopping instance %s", id)
|
log.Printf("Stopping instance %s", id)
|
||||||
_ = dockerComposeStop(*dataDir, id)
|
_ = dockerComposeStop(*dataDir, id)
|
||||||
info.Status = "stopped"
|
inst[id].Status = "stopped"
|
||||||
_ = sendMessage(WSMessage{Action: "instance_stopped", InstanceID: id})
|
go sendMessage(WSMessage{Action: "instance_stopped", InstanceID: id})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = saveInstances(*dataDir, inst)
|
_ = saveInstances(*dataDir, inst)
|
||||||
@@ -127,6 +136,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) {
|
func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in startTailscaleAndReport: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
ip, err := startTailscale(dataDir, nodeID, headscaleURL, authKey)
|
ip, err := startTailscale(dataDir, nodeID, headscaleURL, authKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Tailscale error: %v", err)
|
log.Printf("Tailscale error: %v", err)
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
type podmanMachine struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
VMType string `json:"vm_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensurePodmanMachineDNS configures public DNS resolvers on running Podman
|
||||||
|
// machines on Windows and macOS. This is required because the Podman VM does
|
||||||
|
// not always inherit a working DNS from the host, which prevents pulling
|
||||||
|
// images and reaching api.wordpress.org from containers.
|
||||||
|
func ensurePodmanMachineDNS() {
|
||||||
|
if runtime.GOOS != "windows" && runtime.GOOS != "darwin" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if getContainerEngine() != "podman" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := exec.Command("podman", "machine", "list", "--format", "json").Output()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ensurePodmanMachineDNS: cannot list machines: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var machines []podmanMachine
|
||||||
|
if err := json.Unmarshal(out, &machines); err != nil {
|
||||||
|
log.Printf("ensurePodmanMachineDNS: cannot parse machine list: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range machines {
|
||||||
|
if !m.Running {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := configurePodmanMachineDNS(m.Name); err != nil {
|
||||||
|
log.Printf("ensurePodmanMachineDNS: failed for %s: %v", m.Name, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("ensurePodmanMachineDNS: DNS configured for %s", m.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configurePodmanMachineDNS(name string) error {
|
||||||
|
cmd := exec.Command(
|
||||||
|
"podman", "machine", "ssh", name,
|
||||||
|
"sudo", "sh", "-c",
|
||||||
|
"echo nameserver 8.8.8.8 > /etc/resolv.conf && echo nameserver 1.1.1.1 >> /etc/resolv.conf",
|
||||||
|
)
|
||||||
|
hideWindow(cmd)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %s", err, string(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+19
-5
@@ -251,9 +251,16 @@ func sendUILog(message string) {
|
|||||||
"level": "info",
|
"level": "info",
|
||||||
}
|
}
|
||||||
for _, conn := range conns {
|
for _, conn := range conns {
|
||||||
if err := conn.WriteJSON(msg); err != nil {
|
func(c *websocket.Conn) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in sendUILog: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := c.WriteJSON(msg); err != nil {
|
||||||
// Client may have disconnected; ignore.
|
// Client may have disconnected; ignore.
|
||||||
}
|
}
|
||||||
|
}(conn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,9 +285,16 @@ func broadcastUI(msg map[string]interface{}) {
|
|||||||
uiConnectionsMu.RUnlock()
|
uiConnectionsMu.RUnlock()
|
||||||
|
|
||||||
for _, conn := range conns {
|
for _, conn := range conns {
|
||||||
if err := conn.WriteJSON(msg); err != nil {
|
func(c *websocket.Conn) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in broadcastUI: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := c.WriteJSON(msg); err != nil {
|
||||||
// Ignore write errors for disconnected clients.
|
// Ignore write errors for disconnected clients.
|
||||||
}
|
}
|
||||||
|
}(conn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,7 +430,7 @@ func uiStopInstance(dataDir, instanceID string) {
|
|||||||
inst[instanceID].Status = "stopped"
|
inst[instanceID].Status = "stopped"
|
||||||
_ = saveInstances(dataDir, inst)
|
_ = saveInstances(dataDir, inst)
|
||||||
}
|
}
|
||||||
_ = sendMessage(WSMessage{Action: "instance_stopped", InstanceID: instanceID})
|
go sendMessage(WSMessage{Action: "instance_stopped", InstanceID: instanceID})
|
||||||
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,7 +441,7 @@ func uiDeleteInstance(dataDir, instanceID string) {
|
|||||||
}
|
}
|
||||||
dockerComposeRm(dataDir, instanceID)
|
dockerComposeRm(dataDir, instanceID)
|
||||||
removeInstance(dataDir, instanceID)
|
removeInstance(dataDir, instanceID)
|
||||||
_ = sendMessage(WSMessage{Action: "instance_deleted", InstanceID: instanceID})
|
go sendMessage(WSMessage{Action: "instance_deleted", InstanceID: instanceID})
|
||||||
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,7 +460,7 @@ func uiResetInstance(dataDir, nodeID, instanceID string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
dockerComposeRm(dataDir, instanceID)
|
dockerComposeRm(dataDir, instanceID)
|
||||||
handleStartInstance(dataDir, nodeID, instanceID, info.TemplateName, string(composeBytes), info.Port)
|
handleStartInstance(dataDir, nodeID, instanceID, info.TemplateName, string(composeBytes), "", info.Port)
|
||||||
}
|
}
|
||||||
|
|
||||||
// instanceContainersExist returns true if compose containers already exist for this instance.
|
// instanceContainersExist returns true if compose containers already exist for this instance.
|
||||||
|
|||||||
+66
-7
@@ -18,6 +18,7 @@ type WSMessage struct {
|
|||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
Port int `json:"port,omitempty"`
|
Port int `json:"port,omitempty"`
|
||||||
ComposeConfig string `json:"composeConfig,omitempty"`
|
ComposeConfig string `json:"composeConfig,omitempty"`
|
||||||
|
InitScript string `json:"initScript,omitempty"`
|
||||||
StudentId string `json:"studentId,omitempty"`
|
StudentId string `json:"studentId,omitempty"`
|
||||||
StudentName string `json:"studentName,omitempty"`
|
StudentName string `json:"studentName,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
@@ -101,7 +102,14 @@ func notifyUI(msg map[string]interface{}) {
|
|||||||
|
|
||||||
log.Printf("notifyUI: broadcasting to %d UI clients", len(notifiers))
|
log.Printf("notifyUI: broadcasting to %d UI clients", len(notifiers))
|
||||||
for _, fn := range notifiers {
|
for _, fn := range notifiers {
|
||||||
go fn(msg)
|
go func(notify uiNotifier) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in notifyUI goroutine: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
notify(msg)
|
||||||
|
}(fn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +200,12 @@ func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) {
|
func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in handleMessage (action=%s): %v", msg.Action, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
switch msg.Action {
|
switch msg.Action {
|
||||||
case "set_token":
|
case "set_token":
|
||||||
if msg.Token != "" {
|
if msg.Token != "" {
|
||||||
@@ -251,10 +265,15 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
|||||||
hsURL, hsKey := getHeadscaleConfig()
|
hsURL, hsKey := getHeadscaleConfig()
|
||||||
if hsURL == "" || hsKey == "" {
|
if hsURL == "" || hsKey == "" {
|
||||||
log.Printf("Cannot start VPN: headscale config missing")
|
log.Printf("Cannot start VPN: headscale config missing")
|
||||||
sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: "headscale config missing"})
|
go sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: "headscale config missing"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in start_vpn goroutine: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey)
|
ip, err := startTailscale(dataDir, nodeID, hsURL, hsKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("start_vpn error: %v", err)
|
log.Printf("start_vpn error: %v", err)
|
||||||
@@ -282,7 +301,14 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
|||||||
case "stop_vpn":
|
case "stop_vpn":
|
||||||
log.Printf("Server requested VPN stop")
|
log.Printf("Server requested VPN stop")
|
||||||
stopTailscale()
|
stopTailscale()
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in stop_vpn goroutine: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
sendMessage(WSMessage{Action: "vpn_stopped", NodeID: nodeID})
|
sendMessage(WSMessage{Action: "vpn_stopped", NodeID: nodeID})
|
||||||
|
}()
|
||||||
case "activation_failed":
|
case "activation_failed":
|
||||||
log.Printf("handleMessage: activation_failed received, error=%s", msg.Error)
|
log.Printf("handleMessage: activation_failed received, error=%s", msg.Error)
|
||||||
notifyUI(map[string]interface{}{
|
notifyUI(map[string]interface{}{
|
||||||
@@ -291,19 +317,27 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
|||||||
})
|
})
|
||||||
case "start":
|
case "start":
|
||||||
log.Printf("Start instance %s on port %d", msg.InstanceID, msg.Port)
|
log.Printf("Start instance %s on port %d", msg.InstanceID, msg.Port)
|
||||||
go handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.Port)
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in start goroutine instance=%s: %v", msg.InstanceID, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.InitScript, msg.Port)
|
||||||
|
}()
|
||||||
case "stop":
|
case "stop":
|
||||||
log.Printf("Stop instance %s", msg.InstanceID)
|
log.Printf("Stop instance %s", msg.InstanceID)
|
||||||
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
|
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
|
||||||
removeTailscaleServe(inst[msg.InstanceID].Port)
|
removeTailscaleServe(inst[msg.InstanceID].Port)
|
||||||
}
|
}
|
||||||
if err := dockerComposeDown(dataDir, msg.InstanceID); err != nil {
|
if err := dockerComposeStop(dataDir, msg.InstanceID); err != nil {
|
||||||
log.Printf("dockerComposeDown error: %v", err)
|
log.Printf("dockerComposeStop error: %v", err)
|
||||||
}
|
}
|
||||||
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
|
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
|
||||||
inst[msg.InstanceID].Status = "stopped"
|
inst[msg.InstanceID].Status = "stopped"
|
||||||
_ = saveInstances(dataDir, inst)
|
_ = saveInstances(dataDir, inst)
|
||||||
}
|
}
|
||||||
|
go sendMessage(WSMessage{Action: "instance_stopped", InstanceID: msg.InstanceID})
|
||||||
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
case "delete":
|
case "delete":
|
||||||
log.Printf("Delete instance %s", msg.InstanceID)
|
log.Printf("Delete instance %s", msg.InstanceID)
|
||||||
@@ -312,17 +346,37 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
|||||||
}
|
}
|
||||||
dockerComposeRm(dataDir, msg.InstanceID)
|
dockerComposeRm(dataDir, msg.InstanceID)
|
||||||
removeInstance(dataDir, msg.InstanceID)
|
removeInstance(dataDir, msg.InstanceID)
|
||||||
|
go sendMessage(WSMessage{Action: "instance_deleted", InstanceID: msg.InstanceID})
|
||||||
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
case "reset":
|
case "reset":
|
||||||
log.Printf("Reset instance %s", msg.InstanceID)
|
log.Printf("Reset instance %s", msg.InstanceID)
|
||||||
dockerComposeRm(dataDir, msg.InstanceID)
|
dockerComposeRm(dataDir, msg.InstanceID)
|
||||||
go handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.Port)
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in reset goroutine instance=%s: %v", msg.InstanceID, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
handleStartInstance(dataDir, nodeID, msg.InstanceID, msg.Type, msg.ComposeConfig, msg.InitScript, msg.Port)
|
||||||
|
}()
|
||||||
default:
|
default:
|
||||||
log.Printf("Unknown action: %s", msg.Action)
|
log.Printf("Unknown action: %s", msg.Action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleStartInstance(dataDir, nodeID, instanceID, instanceType, composeConfig string, port int) {
|
func handleStartInstance(dataDir, nodeID, instanceID, instanceType, composeConfig, initScript string, port int) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("PANIC in handleStartInstance instance=%s: %v", instanceID, r)
|
||||||
|
_ = upsertInstance(dataDir, &InstanceInfo{ID: instanceID, TemplateName: instanceType, Port: port, Status: "error"})
|
||||||
|
sendMessage(WSMessage{Action: "instance_error", InstanceID: instanceID, Error: fmt.Sprintf("internal panic: %v", r)})
|
||||||
|
sendInstanceProgress(instanceID, "start", "0", "Erreur interne")
|
||||||
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("handleStartInstance begin: instance=%s type=%s port=%d dataDir=%s initScriptLen=%d", instanceID, instanceType, port, dataDir, len(initScript))
|
||||||
|
|
||||||
notifyInstanceProgress := func(percent, message string) {
|
notifyInstanceProgress := func(percent, message string) {
|
||||||
sendInstanceProgress(instanceID, "start", percent, message)
|
sendInstanceProgress(instanceID, "start", percent, message)
|
||||||
}
|
}
|
||||||
@@ -344,6 +398,11 @@ func handleStartInstance(dataDir, nodeID, instanceID, instanceType, composeConfi
|
|||||||
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if initScript != "" {
|
||||||
|
if err := writeInitScript(dataDir, instanceID, initScript); err != nil {
|
||||||
|
log.Printf("writeInitScript error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
notifyInstanceProgress("30", "Configuration de l'application...")
|
notifyInstanceProgress("30", "Configuration de l'application...")
|
||||||
|
|
||||||
if err := dockerComposeUp(dataDir, instanceID); err != nil {
|
if err := dockerComposeUp(dataDir, instanceID); err != nil {
|
||||||
|
|||||||
@@ -128,6 +128,13 @@ export async function POST(req: NextRequest) {
|
|||||||
.replace(/{INSTANCE_ID}/g, instance.id)
|
.replace(/{INSTANCE_ID}/g, instance.id)
|
||||||
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
||||||
.replace(/{PUBLIC_DOMAIN}/g, "localhost"),
|
.replace(/{PUBLIC_DOMAIN}/g, "localhost"),
|
||||||
|
initScript: template.initScript
|
||||||
|
? template.initScript
|
||||||
|
.replace(/{PORT}/g, String(instance.port))
|
||||||
|
.replace(/{INSTANCE_ID}/g, instance.id)
|
||||||
|
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
||||||
|
.replace(/{PUBLIC_DOMAIN}/g, "localhost")
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
@@ -161,8 +168,8 @@ export async function PATCH(req: NextRequest) {
|
|||||||
const publicUrl = domain ? `https://${publicDomain}` : null;
|
const publicUrl = domain ? `https://${publicDomain}` : null;
|
||||||
|
|
||||||
if (action === "stop") {
|
if (action === "stop") {
|
||||||
sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id });
|
const sent = sendToNode(instance.nodeId, { action: "stop", instanceId: instance.id });
|
||||||
await prisma.instance.update({ where: { id }, data: { status: "stopped" } });
|
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
|
||||||
} else if (action === "start") {
|
} else if (action === "start") {
|
||||||
const sent = sendToNode(instance.nodeId, {
|
const sent = sendToNode(instance.nodeId, {
|
||||||
action: "start",
|
action: "start",
|
||||||
@@ -174,6 +181,13 @@ export async function PATCH(req: NextRequest) {
|
|||||||
.replace(/{INSTANCE_ID}/g, instance.id)
|
.replace(/{INSTANCE_ID}/g, instance.id)
|
||||||
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
||||||
.replace(/{PUBLIC_DOMAIN}/g, "localhost"),
|
.replace(/{PUBLIC_DOMAIN}/g, "localhost"),
|
||||||
|
initScript: instance.template.initScript
|
||||||
|
? instance.template.initScript
|
||||||
|
.replace(/{PORT}/g, String(instance.port))
|
||||||
|
.replace(/{INSTANCE_ID}/g, instance.id)
|
||||||
|
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
||||||
|
.replace(/{PUBLIC_DOMAIN}/g, "localhost")
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
|
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
|
||||||
} else if (action === "reset") {
|
} else if (action === "reset") {
|
||||||
@@ -187,6 +201,13 @@ export async function PATCH(req: NextRequest) {
|
|||||||
.replace(/{INSTANCE_ID}/g, instance.id)
|
.replace(/{INSTANCE_ID}/g, instance.id)
|
||||||
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
||||||
.replace(/{PUBLIC_DOMAIN}/g, "localhost"),
|
.replace(/{PUBLIC_DOMAIN}/g, "localhost"),
|
||||||
|
initScript: instance.template.initScript
|
||||||
|
? instance.template.initScript
|
||||||
|
.replace(/{PORT}/g, String(instance.port))
|
||||||
|
.replace(/{INSTANCE_ID}/g, instance.id)
|
||||||
|
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
||||||
|
.replace(/{PUBLIC_DOMAIN}/g, "localhost")
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
|
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
const AGENT_VERSION = "0.3.0";
|
const AGENT_VERSION = "0.3.8";
|
||||||
const AGENT_BIN_NAME = "studioE5-agent";
|
const AGENT_BIN_NAME = "studioE5-agent";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -13,13 +13,33 @@ export default function DownloadPage() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Windows</CardTitle>
|
<CardTitle>Windows (.exe)</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-muted-foreground mb-4">Agent studioE5 pour Windows (64 bits)</p>
|
<p className="text-sm text-muted-foreground mb-4">Agent studioE5 pour Windows (64 bits). Nécessite Tailscale installé séparément ou les binaires dans <code>tailscale-bin/windows/</code>.</p>
|
||||||
<a href={`/${AGENT_BIN_NAME}-v${AGENT_VERSION}.exe`} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger (.exe)</a>
|
<a href={`/${AGENT_BIN_NAME}-v${AGENT_VERSION}.exe`} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger (.exe)</a>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Windows (archive)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">Archive complète incluant l'agent, Tailscale et le README Windows.</p>
|
||||||
|
<a href={`/${AGENT_BIN_NAME}-v${AGENT_VERSION}-windows.zip`} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger (.zip)</a>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Linux</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">Agent studioE5 pour Linux (64 bits).</p>
|
||||||
|
<a href={`/${AGENT_BIN_NAME}-v${AGENT_VERSION}`} download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger (Linux)</a>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -264,35 +264,37 @@ export function initWebSocketServer(wss: WebSocketServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg.action === "instance_started" && msg.instanceId) {
|
if (msg.action === "instance_started" && msg.instanceId) {
|
||||||
await prisma.instance.update({
|
const { count } = await prisma.instance.updateMany({
|
||||||
where: { id: msg.instanceId },
|
where: { id: msg.instanceId },
|
||||||
data: { status: "running" },
|
data: { status: "running" },
|
||||||
});
|
});
|
||||||
|
if (count) console.log("[WS] Instance started:", msg.instanceId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.action === "instance_stopped" && msg.instanceId) {
|
if (msg.action === "instance_stopped" && msg.instanceId) {
|
||||||
await prisma.instance.update({
|
const { count } = await prisma.instance.updateMany({
|
||||||
where: { id: msg.instanceId },
|
where: { id: msg.instanceId },
|
||||||
data: { status: "stopped" },
|
data: { status: "stopped" },
|
||||||
});
|
});
|
||||||
console.log("[WS] Instance stopped:", msg.instanceId);
|
if (count) console.log("[WS] Instance stopped:", msg.instanceId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.action === "instance_deleted" && msg.instanceId) {
|
if (msg.action === "instance_deleted" && msg.instanceId) {
|
||||||
await prisma.instance.delete({
|
const { count } = await prisma.instance.deleteMany({
|
||||||
where: { id: msg.instanceId },
|
where: { id: msg.instanceId },
|
||||||
});
|
});
|
||||||
console.log("[WS] Instance deleted:", msg.instanceId);
|
if (count) console.log("[WS] Instance deleted:", msg.instanceId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.action === "instance_error" && msg.instanceId) {
|
if (msg.action === "instance_error" && msg.instanceId) {
|
||||||
await prisma.instance.update({
|
const { count } = await prisma.instance.updateMany({
|
||||||
where: { id: msg.instanceId },
|
where: { id: msg.instanceId },
|
||||||
data: { status: "error" },
|
data: { status: "error" },
|
||||||
});
|
});
|
||||||
|
if (count) console.log("[WS] Instance error:", msg.instanceId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Template" ADD COLUMN "initScript" TEXT;
|
||||||
@@ -91,6 +91,7 @@ model Template {
|
|||||||
type String
|
type String
|
||||||
dockerImage String
|
dockerImage String
|
||||||
composeConfig String
|
composeConfig String
|
||||||
|
initScript String?
|
||||||
isPublic Boolean @default(true)
|
isPublic Boolean @default(true)
|
||||||
establishmentId String?
|
establishmentId String?
|
||||||
establishment Establishment? @relation(fields: [establishmentId], references: [id], onDelete: Cascade)
|
establishment Establishment? @relation(fields: [establishmentId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
+66
-2
@@ -1,5 +1,7 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -19,6 +21,11 @@ async function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const wpReadyInitScript = fs.readFileSync(
|
||||||
|
path.join(__dirname, "../templates/wordpress-ready/wp-init.sh"),
|
||||||
|
"utf-8"
|
||||||
|
);
|
||||||
|
|
||||||
const templates = [
|
const templates = [
|
||||||
{
|
{
|
||||||
name: "WordPress latest vierge",
|
name: "WordPress latest vierge",
|
||||||
@@ -50,6 +57,30 @@ async function main() {
|
|||||||
dbPassword: "wordpress",
|
dbPassword: "wordpress",
|
||||||
dbRootPassword: "rootpassword",
|
dbRootPassword: "rootpassword",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "WordPress latest prêt à l'emploi",
|
||||||
|
type: "wordpress-ready",
|
||||||
|
dockerImage: "wordpress:latest",
|
||||||
|
dbImage: "mariadb:10.11",
|
||||||
|
dbName: "wordpress",
|
||||||
|
dbUser: "wordpress",
|
||||||
|
dbPassword: "wordpress",
|
||||||
|
dbRootPassword: "rootpassword",
|
||||||
|
ready: true,
|
||||||
|
initScript: wpReadyInitScript,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "WordPress 7.0.0 prêt à l'emploi",
|
||||||
|
type: "wordpress-ready",
|
||||||
|
dockerImage: "wordpress:7.0.0-php8.3",
|
||||||
|
dbImage: "mariadb:10.11",
|
||||||
|
dbName: "wordpress",
|
||||||
|
dbUser: "wordpress",
|
||||||
|
dbPassword: "wordpress",
|
||||||
|
dbRootPassword: "rootpassword",
|
||||||
|
ready: true,
|
||||||
|
initScript: wpReadyInitScript,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "PrestaShop 9 vierge (edubox)",
|
name: "PrestaShop 9 vierge (edubox)",
|
||||||
type: "prestashop",
|
type: "prestashop",
|
||||||
@@ -66,6 +97,7 @@ async function main() {
|
|||||||
const dbHost = "db";
|
const dbHost = "db";
|
||||||
const dbPort = "3306";
|
const dbPort = "3306";
|
||||||
const isPrestaShop = t.type === "prestashop";
|
const isPrestaShop = t.type === "prestashop";
|
||||||
|
const isWordPressReady = (t as any).ready === true;
|
||||||
|
|
||||||
const appEnv = isPrestaShop
|
const appEnv = isPrestaShop
|
||||||
? ` DB_SERVER: ${dbHost}
|
? ` DB_SERVER: ${dbHost}
|
||||||
@@ -93,6 +125,12 @@ async function main() {
|
|||||||
WORDPRESS_DB_PREFIX: wp_
|
WORDPRESS_DB_PREFIX: wp_
|
||||||
# No hardcoded WP_HOME/WP_SITEURL so WordPress auto-detects from the Host header`;
|
# No hardcoded WP_HOME/WP_SITEURL so WordPress auto-detects from the Host header`;
|
||||||
|
|
||||||
|
const appDNS = isWordPressReady
|
||||||
|
? ` dns:
|
||||||
|
- 8.8.8.8
|
||||||
|
- 1.1.1.1`
|
||||||
|
: "";
|
||||||
|
|
||||||
const appVolumes = isPrestaShop
|
const appVolumes = isPrestaShop
|
||||||
? ` volumes:
|
? ` volumes:
|
||||||
- app_data:/var/www/html`
|
- app_data:/var/www/html`
|
||||||
@@ -100,6 +138,28 @@ async function main() {
|
|||||||
- app_data:/var/www/html
|
- app_data:/var/www/html
|
||||||
- {MU_PLUGINS_DIR}/edubox-public-url.php:/var/www/html/wp-content/mu-plugins/edubox-public-url.php:ro`;
|
- {MU_PLUGINS_DIR}/edubox-public-url.php:/var/www/html/wp-content/mu-plugins/edubox-public-url.php:ro`;
|
||||||
|
|
||||||
|
const wpInitService = isWordPressReady
|
||||||
|
? ` wp-init:
|
||||||
|
image: wordpress:cli
|
||||||
|
user: "0:0"
|
||||||
|
environment:
|
||||||
|
WORDPRESS_DB_HOST: ${dbHost}:${dbPort}
|
||||||
|
WORDPRESS_DB_NAME: ${t.dbName}
|
||||||
|
WORDPRESS_DB_USER: ${t.dbUser}
|
||||||
|
WORDPRESS_DB_PASSWORD: ${t.dbPassword}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
app:
|
||||||
|
condition: service_started
|
||||||
|
volumes:
|
||||||
|
- app_data:/var/www/html
|
||||||
|
- ./wp-init.sh:/wp-init.sh:ro
|
||||||
|
restart: "no"
|
||||||
|
entrypoint: ["/bin/sh", "/wp-init.sh"]
|
||||||
|
`
|
||||||
|
: "";
|
||||||
|
|
||||||
const composeConfig = `services:
|
const composeConfig = `services:
|
||||||
db:
|
db:
|
||||||
image: ${t.dbImage}
|
image: ${t.dbImage}
|
||||||
@@ -123,24 +183,28 @@ async function main() {
|
|||||||
environment:
|
environment:
|
||||||
${appEnv}
|
${appEnv}
|
||||||
INSTANCE_ID: {INSTANCE_ID}
|
INSTANCE_ID: {INSTANCE_ID}
|
||||||
|
${appDNS}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
${appVolumes}
|
${appVolumes}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
${wpInitService}volumes:
|
||||||
db_data:
|
db_data:
|
||||||
app_data:
|
app_data:
|
||||||
`;
|
`;
|
||||||
|
const initScript = isWordPressReady ? wpReadyInitScript : null;
|
||||||
|
|
||||||
await prisma.template.upsert({
|
await prisma.template.upsert({
|
||||||
where: { id: `${t.type}-${t.dockerImage.replace(/[:\/]/g, "-")}` },
|
where: { id: `${t.type}-${t.dockerImage.replace(/[:\/]/g, "-")}` },
|
||||||
update: { composeConfig },
|
update: { composeConfig, initScript },
|
||||||
create: {
|
create: {
|
||||||
id: `${t.type}-${t.dockerImage.replace(/[:\/]/g, "-")}`,
|
id: `${t.type}-${t.dockerImage.replace(/[:\/]/g, "-")}`,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
type: t.type,
|
type: t.type,
|
||||||
dockerImage: t.dockerImage,
|
dockerImage: t.dockerImage,
|
||||||
composeConfig,
|
composeConfig,
|
||||||
|
initScript,
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
createdBy: "system",
|
createdBy: "system",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
WP_PATH=/var/www/html
|
||||||
|
FLAG=$WP_PATH/.studioe5-init-done
|
||||||
|
WP_USER=www-data
|
||||||
|
|
||||||
|
if [ -f "$FLAG" ]; then
|
||||||
|
echo "[studioE5] WordPress already initialized."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[studioE5] Waiting for WordPress config and database..."
|
||||||
|
until [ -f "$WP_PATH/wp-config.php" ] && wp --path="$WP_PATH" db query "SELECT 1" --allow-root > /dev/null 2>&1; do
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[studioE5] Fixing permissions..."
|
||||||
|
# Ensure the web server (www-data) can write to wp-content and wp-config.php.
|
||||||
|
chmod -R 777 "$WP_PATH/wp-content"
|
||||||
|
chmod 666 "$WP_PATH/wp-config.php"
|
||||||
|
|
||||||
|
run_wp() {
|
||||||
|
su -s /bin/sh "$WP_USER" -c "wp --path=$WP_PATH $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[studioE5] Installing WordPress..."
|
||||||
|
run_wp "core install --url='{PUBLIC_URL}' --title='Mon site wordpress' --admin_user='admin' --admin_password='admin' --admin_email='admin@example.com' --skip-email --allow-root"
|
||||||
|
|
||||||
|
echo "[studioE5] Setting language to French..."
|
||||||
|
run_wp "language core install fr_FR --activate --allow-root" || true
|
||||||
|
|
||||||
|
echo "[studioE5] Installing and activating theme Astra..."
|
||||||
|
run_wp "theme install astra --activate --allow-root" || true
|
||||||
|
|
||||||
|
echo "[studioE5] Installing plugins..."
|
||||||
|
run_wp "plugin install wordpress-seo --allow-root" || true
|
||||||
|
run_wp "plugin install ultimate-addons-for-gutenberg --activate --allow-root" || true
|
||||||
|
|
||||||
|
echo "[studioE5] Disabling automatic updates..."
|
||||||
|
run_wp "config set AUTOMATIC_UPDATER_DISABLED true --raw --allow-root" || true
|
||||||
|
run_wp "config set WP_AUTO_UPDATE_CORE false --raw --allow-root" || true
|
||||||
|
run_wp "config set AUTO_UPDATE_PLUGIN false --raw --allow-root" || true
|
||||||
|
run_wp "config set AUTO_UPDATE_THEME false --raw --allow-root" || true
|
||||||
|
|
||||||
|
touch "$FLAG"
|
||||||
|
echo "[studioE5] Initialization complete."
|
||||||
Reference in New Issue
Block a user