commit 7e27f87d6f467f167d3c1c01013647c3c2ed7bbe Author: Pierre Date: Mon Dec 15 19:09:00 2025 +0100 Initial commit - SOGOMS v1.0.0 - sogoctl: supervisor avec health checks et restart auto - sogoway: gateway HTTP, auth JWT, routing par hostname - sogoms-db: microservice MariaDB avec pool par application - Protocol IPC Unix socket JSON length-prefixed - Config YAML multi-application (prokov) - Deploy script pour container Alpine gw3 ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..929e157 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Binaries +bin/ +*.exe +sogoctl +sogoway +sogoms-db + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store + +# Logs +*.log + +# Secrets (never commit) +/secrets/ + +# Temp +*.tar.gz diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100755 index 0000000..ffcb26d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,46 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Rรจgles de travail + +- Soit concis +- Ne fais rien sans validation utilisateur + +## Project Overview + +SOGOMS (Service Oriented GO MicroServices) - plateforme SaaS modulaire, multi-tenant, configurable. + +**Principes:** modularitรฉ (1 feature = 1 binaire Go), configuration YAML, multi-tenant natif, un container avec plusieurs processus supervisรฉs. + +## Architecture + +| Binaire | Rรดle | Port/Socket | +|---------|------|-------------| +| **sogoctl** | Superviseur PID 1, health checks, scaling | TCP :9000 | +| **sogoway** | Gateway HTTP, auth JWT, routing | TCP :8080 | +| **sogorch** | Orchestrateur scรฉnarios YAML | Unix socket | +| **sogoms-db** | Accรจs MariaDB | Unix socket | +| **sogoms-pdf** | Gรฉnรฉration PDF | Unix socket | +| **sogoms-email** | Envoi emails | Unix socket | +| **sogoms-storage** | Gestion fichiers | Unix socket | + +**Flux:** Client โ†’ Nginx(:443) โ†’ Sogoway(:8080) โ†’ Sogorch โ†’ Sogoms-* + +## Structure + +``` +cmd/{sogoctl,sogoway,sogorch}/main.go +cmd/sogoms/{db,pdf,email,storage}/main.go +internal/{protocol,pool,config,scenario,auth,registry}/ +config/{sogoctl,sogoway}.yaml +config/{tenants,routes,scenarios}/*.yaml +``` + +## Communication + +Unix socket JSON length-prefixed (4 bytes length + JSON payload). + +## Dรฉpendances + +net/http standard (Go 1.22+), go-yaml/v3, go-sql-driver/mysql, chromedp (PDF) diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9d561d --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# SOGOMS + +**Service Oriented GO MicroServices** - Plateforme SaaS modulaire multi-tenant. + +## Architecture + +``` +Client โ†’ Nginx(:443) โ†’ Sogoway(:8080) โ†’ Sogoms-db โ†’ MariaDB + โ†“ + Unix Socket +``` + +| Binaire | Rรดle | Port/Socket | +|---------|------|-------------| +| `sogoctl` | Superviseur PID 1, health checks, restart auto | - | +| `sogoway` | Gateway HTTP, auth JWT, routing par hostname | TCP :8080 | +| `sogoms-db` | Accรจs MariaDB, pool par application | Unix socket | + +## Dรฉploiement + +```bash +./deploy.sh +``` + +Dรฉploie sur le container `gw3` (Alpine) via IN3. + +## Lancement + +Sur gw3 : + +```bash +/opt/sogoms/bin/sogoctl +``` + +## Configuration + +Chaque application cliente a son fichier dans `config/routes/` : + +```yaml +# config/routes/prokov.yaml +app: prokov +hosts: + - prokov.unikoffice.com +database: + host: 13.23.33.4 + user: prokov_user + password_file: /secrets/prokov_db_pass + name: prokov +auth: + jwt_secret_file: /secrets/prokov_jwt_secret + jwt_expiry: 24h +``` + +## Structure + +``` +cmd/ + sogoctl/main.go # Superviseur + sogoway/main.go # Gateway HTTP + sogoms/db/main.go # Microservice DB +internal/ + protocol/ # IPC Unix socket (JSON length-prefixed) + config/ # Chargement YAML, registry par host + auth/ # JWT (HS256), bcrypt passwords +config/ + sogoctl.yaml # Services ร  superviser + routes/*.yaml # Config par application + scenarios/ # Scรฉnarios YAML (V2) +``` + +## API Endpoints + +```bash +# Health check +curl http://localhost:8080/health + +# Login +curl -X POST http://localhost:8080/api/auth/login \ + -H "Host: prokov.unikoffice.com" \ + -H "Content-Type: application/json" \ + -d '{"email":"user@example.com","password":"secret"}' + +# User info (avec token) +curl http://localhost:8080/api/auth/me \ + -H "Host: prokov.unikoffice.com" \ + -H "Authorization: Bearer " +``` + +## Prรฉrequis + +- Go 1.22+ +- MariaDB/MySQL +- Container Alpine (gw3) + +## Licence + +Propriรฉtaire diff --git a/TODO.md b/TODO.md new file mode 100755 index 0000000..e9ab227 --- /dev/null +++ b/TODO.md @@ -0,0 +1,92 @@ +# TODO - SOGOMS V1 Minimale + +Objectif : valider l'architecture avec 2-3 microservices basiques. + +## Phase 0 : Infrastructure + +- [x] **Container gw3** : Alpine sur IN3 (13.23.33.5) +- [x] **Config Prokov** : routes + scรฉnarios YAML (auth, projects, tasks, tags, statuses) +- [x] **Nginx host IN3** : routing /api/ โ†’ gw3:8080, / โ†’ dva-front + +## Phase 1 : Protocole IPC + +- [x] `internal/protocol/message.go` : structs Request/Response JSON +- [x] `internal/protocol/server.go` : listener Unix socket +- [x] `internal/protocol/client.go` : client pour appeler les services + +## Phase 2 : Microservice DB + +- [x] `cmd/sogoms/db/main.go` : point d'entrรฉe +- [x] Connexion MariaDB (pool par application) +- [x] Action `query` : SELECT multi-rรฉsultats +- [x] Action `query_one` : SELECT un rรฉsultat +- [x] Action `insert` : INSERT retourne insert_id +- [x] Action `update` : UPDATE retourne affected_rows +- [x] Action `delete` : DELETE retourne affected_rows +- [x] ร‰coute sur `/run/sogoms-db.1.sock` +- [x] Test standalone sogoms-db + +## Phase 3 : Config + +- [x] `internal/config/config.go` : lecture YAML + registry par host +- [x] `internal/config/routes.go` : parser routes (intรฉgrรฉ dans config.go) + +## Phase 4 : Gateway HTTP + +- [x] `cmd/sogoway/main.go` : serveur HTTP :8080 +- [x] Routing par host โ†’ charge le bon fichier routes (prokov.yaml) +- [x] `internal/auth/jwt.go` : gรฉnรฉration + validation JWT (HS256) +- [x] `internal/auth/password.go` : hash + verify password (bcrypt) +- [x] Endpoint `POST /api/auth/login` : vรฉrifie credentials, retourne JWT +- [x] Endpoint `GET /api/auth/me` : valide JWT, retourne user +- [x] Endpoint `POST /api/auth/register` : crรฉe user, retourne JWT +- [x] Communication avec sogoms-db via Unix socket +- [x] Test standalone sogoway + +## Phase 5 : Superviseur + +- [x] `cmd/sogoctl/main.go` : point d'entrรฉe +- [x] Config `config/sogoctl.yaml` : services ร  lancer +- [x] Lancement sogoms-db + sogoway (avec dรฉpendances) +- [x] Health check (socket + HTTP) +- [x] Redรฉmarrage automatique si crash + +## Phase 6 : Test de validation + +```bash +# 1. Lancer sogoctl (dรฉmarre les services) +./sogoctl + +# 2. Login +curl -X POST https://prokov.unikoffice.com/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"test@example.com","password":"secret"}' +# โ†’ {"success":true,"data":{"token":"eyJ...","user":{...}}} + +# 3. Vรฉrifier le token +curl https://prokov.unikoffice.com/api/auth/me \ + -H "Authorization: Bearer eyJ..." +# โ†’ {"success":true,"data":{"user":{...}}} +``` + +- [x] Test login OK +- [x] Test /me avec token valide OK +- [x] Test /me sans token โ†’ 401 + +## Phase 7 : Microservice Logs + +- [ ] `cmd/sogoms/logs/main.go` : point d'entrรฉe +- [ ] ร‰coute sur Unix socket `/run/sogoms-logs.1.sock` +- [ ] Actions `log_error`, `log_event` : รฉcriture dans fichiers +- [ ] Format fichiers : `/var/log/sogoms/{app}-{YYYYMMDD}-{type}.log` +- [ ] Rotation automatique : suppression des fichiers > N jours (dรฉfaut 15) +- [ ] Paramรจtre `retention_days` dans config +- [ ] Intรฉgration avec sogoway et sogoms-db + +## Hors scope V1 + +- sogorch (orchestrateur scรฉnarios) +- sogoms-pdf, sogoms-email, sogoms-storage +- Multi-tenant +- Rate limiting +- Exรฉcution dynamique des scรฉnarios YAML diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/clients/prokov/api/config/Database.php b/clients/prokov/api/config/Database.php new file mode 100644 index 0000000..d7f5c3d --- /dev/null +++ b/clients/prokov/api/config/Database.php @@ -0,0 +1,39 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ] + ); + } catch (PDOException $e) { + Response::error('Database connection failed', 500); + exit; + } + } + + return self::$instance; + } + + // Empรชcher le clonage et la dรฉsรฉrialisation + private function __construct() {} + private function __clone() {} + public function __wakeup() {} +} diff --git a/clients/prokov/api/config/config.php b/clients/prokov/api/config/config.php new file mode 100644 index 0000000..f3e9873 --- /dev/null +++ b/clients/prokov/api/config/config.php @@ -0,0 +1,27 @@ +validate([ + 'email' => 'required|email|max:255', + 'password' => 'required|min:6|max:255', + 'name' => 'required|min:2|max:100', + ]); + + $db = Database::getInstance(); + + // Vรฉrifier si l'email existe dรฉjร  + $stmt = $db->prepare('SELECT id FROM users WHERE email = :email'); + $stmt->execute(['email' => $data['email']]); + + if ($stmt->fetch()) { + Response::error('Cet email est dรฉjร  utilisรฉ', 409); + } + + // Crรฉer l'utilisateur + $hashedPassword = password_hash($data['password'], PASSWORD_DEFAULT); + + $stmt = $db->prepare(' + INSERT INTO users (email, password, name) + VALUES (:email, :password, :name) + '); + + $stmt->execute([ + 'email' => $data['email'], + 'password' => $hashedPassword, + 'name' => $data['name'], + ]); + + $userId = (int) $db->lastInsertId(); + + // Crรฉer les statuts par dรฉfaut pour ce nouvel utilisateur + $this->createDefaultStatuses($userId); + + // Crรฉer une session + $sessionId = Session::create($userId); + + Response::success([ + 'session_id' => $sessionId, + 'user' => [ + 'id' => $userId, + 'email' => $data['email'], + 'name' => $data['name'], + ], + ], 'Inscription rรฉussie', 201); + } + + /** + * POST /auth/login + */ + public function login(): void + { + $data = $this->validate([ + 'email' => 'required|email', + 'password' => 'required', + ]); + + $db = Database::getInstance(); + + $stmt = $db->prepare('SELECT id, email, name, password FROM users WHERE email = :email'); + $stmt->execute(['email' => $data['email']]); + $user = $stmt->fetch(); + + if (!$user || !password_verify($data['password'], $user['password'])) { + Response::error('Email ou mot de passe incorrect', 401); + } + + // Crรฉer une session + $sessionId = Session::create($user['id']); + + Response::success([ + 'session_id' => $sessionId, + 'user' => [ + 'id' => $user['id'], + 'email' => $user['email'], + 'name' => $user['name'], + ], + ], 'Connexion rรฉussie'); + } + + /** + * POST /auth/logout + */ + public function logout(): void + { + $sessionId = $this->request->getSessionId(); + + if ($sessionId) { + Session::destroy($sessionId); + } + + Response::success(null, 'Dรฉconnexion rรฉussie'); + } + + /** + * GET /auth/me + */ + public function me(): void + { + $this->requireAuth(); + + Response::success([ + 'user' => $this->user, + ]); + } + + /** + * Crรฉer les statuts par dรฉfaut pour un nouvel utilisateur + */ + private function createDefaultStatuses(int $userId): void + { + $db = Database::getInstance(); + + $defaultStatuses = [ + ['code' => 10, 'name' => 'Backlog', 'color' => '#6B7280', 'position' => 10], + ['code' => 20, 'name' => 'ร€ faire', 'color' => '#3B82F6', 'position' => 20], + ['code' => 30, 'name' => 'En cours', 'color' => '#F59E0B', 'position' => 30], + ['code' => 40, 'name' => 'ร€ tester', 'color' => '#8B5CF6', 'position' => 40], + ['code' => 50, 'name' => 'Livrรฉ', 'color' => '#10B981', 'position' => 50], + ['code' => 60, 'name' => 'Terminรฉ', 'color' => '#059669', 'position' => 60], + ['code' => 70, 'name' => 'Archivรฉ', 'color' => '#9CA3AF', 'position' => 70], + ]; + + $stmt = $db->prepare(' + INSERT INTO statuses (user_id, project_id, code, name, color, position) + VALUES (:user_id, NULL, :code, :name, :color, :position) + '); + + foreach ($defaultStatuses as $status) { + $stmt->execute([ + 'user_id' => $userId, + 'code' => $status['code'], + 'name' => $status['name'], + 'color' => $status['color'], + 'position' => $status['position'], + ]); + } + } +} diff --git a/clients/prokov/api/controllers/ProjectController.php b/clients/prokov/api/controllers/ProjectController.php new file mode 100644 index 0000000..503c989 --- /dev/null +++ b/clients/prokov/api/controllers/ProjectController.php @@ -0,0 +1,359 @@ +requireAuth(); + + $db = Database::getInstance(); + + // Rรฉcupรฉrer tous les projets de l'utilisateur + $stmt = $db->prepare(' + SELECT p.*, + GROUP_CONCAT(t.id) as tag_ids, + GROUP_CONCAT(t.name) as tag_names + FROM projects p + LEFT JOIN project_tags pt ON p.id = pt.project_id + LEFT JOIN tags t ON pt.tag_id = t.id + WHERE p.user_id = :user_id + GROUP BY p.id + ORDER BY p.parent_id ASC, p.position ASC, p.name ASC + '); + + $stmt->execute(['user_id' => $this->getUserId()]); + $projects = $stmt->fetchAll(); + + // Construire l'arborescence + $tree = $this->buildTree($projects); + + Response::success($tree); + } + + /** + * GET /projects/{id} + */ + public function show(): void + { + $this->requireAuth(); + + $id = (int) $this->request->getParam('id'); + $project = $this->findOrFail($id); + + // Rรฉcupรฉrer les tags + $project['tags'] = $this->getProjectTags($id); + + // Rรฉcupรฉrer les sous-projets + $project['children'] = $this->getChildren($id); + + Response::success($project); + } + + /** + * POST /projects + */ + public function store(): void + { + $this->requireAuth(); + + $data = $this->validate([ + 'name' => 'required|min:1|max:100', + 'description' => 'max:65535', + 'parent_id' => 'int', + 'position' => 'int', + ]); + + // Vรฉrifier que le parent appartient ร  l'utilisateur + if (!empty($data['parent_id'])) { + $this->findOrFail((int) $data['parent_id']); + } + + $db = Database::getInstance(); + + $stmt = $db->prepare(' + INSERT INTO projects (user_id, parent_id, name, description, position) + VALUES (:user_id, :parent_id, :name, :description, :position) + '); + + $stmt->execute([ + 'user_id' => $this->getUserId(), + 'parent_id' => $data['parent_id'] ?: null, + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'position' => $data['position'] ?? 0, + ]); + + $projectId = (int) $db->lastInsertId(); + + // Gรฉrer les tags si fournis + $tags = $this->request->get('tags'); + if (is_array($tags)) { + $this->syncTags($projectId, $tags); + } + + $project = $this->findOrFail($projectId); + $project['tags'] = $this->getProjectTags($projectId); + + Response::success($project, 'Projet crรฉรฉ', 201); + } + + /** + * PUT /projects/{id} + */ + public function update(): void + { + $this->requireAuth(); + + $id = (int) $this->request->getParam('id'); + $this->findOrFail($id); + + $data = $this->validate([ + 'name' => 'min:1|max:100', + 'description' => 'max:65535', + 'parent_id' => 'int', + 'position' => 'int', + ]); + + // Vรฉrifier que le parent n'est pas le projet lui-mรชme ou un de ses enfants + if (!empty($data['parent_id'])) { + $parentId = (int) $data['parent_id']; + if ($parentId === $id) { + Response::error('Un projet ne peut pas รชtre son propre parent', 422); + } + $this->findOrFail($parentId); + + // Vรฉrifier que le parent n'est pas un enfant du projet + if ($this->isDescendant($parentId, $id)) { + Response::error('Le parent ne peut pas รชtre un sous-projet', 422); + } + } + + $db = Database::getInstance(); + + $fields = []; + $params = ['id' => $id]; + + if (isset($data['name'])) { + $fields[] = 'name = :name'; + $params['name'] = $data['name']; + } + + if (array_key_exists('description', $data)) { + $fields[] = 'description = :description'; + $params['description'] = $data['description']; + } + + if (array_key_exists('parent_id', $data)) { + $fields[] = 'parent_id = :parent_id'; + $params['parent_id'] = $data['parent_id'] ?: null; + } + + if (isset($data['position'])) { + $fields[] = 'position = :position'; + $params['position'] = $data['position']; + } + + if (!empty($fields)) { + $sql = 'UPDATE projects SET ' . implode(', ', $fields) . ' WHERE id = :id'; + $stmt = $db->prepare($sql); + $stmt->execute($params); + } + + // Gรฉrer les tags si fournis + $tags = $this->request->get('tags'); + if (is_array($tags)) { + $this->syncTags($id, $tags); + } + + $project = $this->findOrFail($id); + $project['tags'] = $this->getProjectTags($id); + + Response::success($project, 'Projet mis ร  jour'); + } + + /** + * DELETE /projects/{id} + */ + public function destroy(): void + { + $this->requireAuth(); + + $id = (int) $this->request->getParam('id'); + $this->findOrFail($id); + + $db = Database::getInstance(); + + // Les sous-projets et tรขches seront supprimรฉs en cascade (FK) + $stmt = $db->prepare('DELETE FROM projects WHERE id = :id'); + $stmt->execute(['id' => $id]); + + Response::success(null, 'Projet supprimรฉ'); + } + + /** + * Trouver un projet ou retourner 404 + */ + private function findOrFail(int $id): array + { + $db = Database::getInstance(); + + $stmt = $db->prepare(' + SELECT * FROM projects + WHERE id = :id AND user_id = :user_id + '); + + $stmt->execute([ + 'id' => $id, + 'user_id' => $this->getUserId(), + ]); + + $project = $stmt->fetch(); + + if (!$project) { + Response::notFound('Projet non trouvรฉ'); + } + + return $project; + } + + /** + * Construire l'arborescence des projets + */ + private function buildTree(array $projects, ?int $parentId = null): array + { + $tree = []; + + foreach ($projects as $project) { + if ($project['parent_id'] == $parentId) { + // Parser les tags + $project['tags'] = []; + if (!empty($project['tag_ids'])) { + $ids = explode(',', $project['tag_ids']); + $names = explode(',', $project['tag_names']); + foreach ($ids as $i => $tagId) { + $project['tags'][] = [ + 'id' => (int) $tagId, + 'name' => $names[$i] ?? '', + ]; + } + } + unset($project['tag_ids'], $project['tag_names']); + + // Rรฉcursion pour les enfants + $project['children'] = $this->buildTree($projects, (int) $project['id']); + + $tree[] = $project; + } + } + + return $tree; + } + + /** + * Rรฉcupรฉrer les tags d'un projet + */ + private function getProjectTags(int $projectId): array + { + $db = Database::getInstance(); + + $stmt = $db->prepare(' + SELECT t.id, t.name, t.color + FROM tags t + JOIN project_tags pt ON t.id = pt.tag_id + WHERE pt.project_id = :project_id + '); + + $stmt->execute(['project_id' => $projectId]); + + return $stmt->fetchAll(); + } + + /** + * Rรฉcupรฉrer les sous-projets directs + */ + private function getChildren(int $projectId): array + { + $db = Database::getInstance(); + + $stmt = $db->prepare(' + SELECT * FROM projects + WHERE parent_id = :parent_id AND user_id = :user_id + ORDER BY position ASC, name ASC + '); + + $stmt->execute([ + 'parent_id' => $projectId, + 'user_id' => $this->getUserId(), + ]); + + return $stmt->fetchAll(); + } + + /** + * Vรฉrifier si un projet est un descendant d'un autre + */ + private function isDescendant(int $projectId, int $ancestorId): bool + { + $db = Database::getInstance(); + + $stmt = $db->prepare(' + SELECT parent_id FROM projects + WHERE id = :id AND user_id = :user_id + '); + + $stmt->execute([ + 'id' => $projectId, + 'user_id' => $this->getUserId(), + ]); + + $project = $stmt->fetch(); + + if (!$project || $project['parent_id'] === null) { + return false; + } + + if ((int) $project['parent_id'] === $ancestorId) { + return true; + } + + return $this->isDescendant((int) $project['parent_id'], $ancestorId); + } + + /** + * Synchroniser les tags d'un projet + */ + private function syncTags(int $projectId, array $tagIds): void + { + $db = Database::getInstance(); + + // Supprimer les associations existantes + $stmt = $db->prepare('DELETE FROM project_tags WHERE project_id = :project_id'); + $stmt->execute(['project_id' => $projectId]); + + // Ajouter les nouvelles associations + if (!empty($tagIds)) { + $stmt = $db->prepare(' + INSERT INTO project_tags (project_id, tag_id) + SELECT :project_id, id FROM tags + WHERE id = :tag_id AND user_id = :user_id + '); + + foreach ($tagIds as $tagId) { + $stmt->execute([ + 'project_id' => $projectId, + 'tag_id' => (int) $tagId, + 'user_id' => $this->getUserId(), + ]); + } + } + } +} diff --git a/clients/prokov/api/controllers/StatusController.php b/clients/prokov/api/controllers/StatusController.php new file mode 100644 index 0000000..1715d2c --- /dev/null +++ b/clients/prokov/api/controllers/StatusController.php @@ -0,0 +1,231 @@ +requireAuth(); + + $db = Database::getInstance(); + + $where = ['user_id = :user_id']; + $params = ['user_id' => $this->getUserId()]; + + $projectId = $this->request->get('project_id'); + $globalOnly = $this->request->get('global'); + + if ($projectId !== null) { + // Statuts du projet + statuts globaux + $where = ['user_id = :user_id AND (project_id = :project_id OR project_id IS NULL)']; + $params['project_id'] = (int) $projectId; + } elseif ($globalOnly !== null) { + $where[] = 'project_id IS NULL'; + } + + $sql = ' + SELECT s.*, + (SELECT COUNT(*) FROM tasks t WHERE t.status_id = s.id) as task_count + FROM statuses s + WHERE ' . implode(' AND ', $where) . ' + ORDER BY s.position ASC, s.code ASC + '; + + $stmt = $db->prepare($sql); + $stmt->execute($params); + $statuses = $stmt->fetchAll(); + + Response::success($statuses); + } + + /** + * GET /statuses/{id} + */ + public function show(): void + { + $this->requireAuth(); + + $id = (int) $this->request->getParam('id'); + $status = $this->findOrFail($id); + + // Nombre de tรขches avec ce statut + $db = Database::getInstance(); + $stmt = $db->prepare('SELECT COUNT(*) as count FROM tasks WHERE status_id = :id'); + $stmt->execute(['id' => $id]); + $status['task_count'] = (int) $stmt->fetch()['count']; + + Response::success($status); + } + + /** + * POST /statuses + */ + public function store(): void + { + $this->requireAuth(); + + $data = $this->validate([ + 'code' => 'required|int', + 'name' => 'required|min:1|max:50', + 'color' => 'max:7', + 'project_id' => 'int', + 'position' => 'int', + ]); + + // Si project_id fourni, vรฉrifier qu'il appartient ร  l'utilisateur + if (!empty($data['project_id'])) { + $this->verifyProject((int) $data['project_id']); + } + + $db = Database::getInstance(); + + $stmt = $db->prepare(' + INSERT INTO statuses (user_id, project_id, code, name, color, position) + VALUES (:user_id, :project_id, :code, :name, :color, :position) + '); + + $stmt->execute([ + 'user_id' => $this->getUserId(), + 'project_id' => $data['project_id'] ?: null, + 'code' => $data['code'], + 'name' => $data['name'], + 'color' => $data['color'] ?? '#6B7280', + 'position' => $data['position'] ?? $data['code'], + ]); + + $statusId = (int) $db->lastInsertId(); + $status = $this->findOrFail($statusId); + + Response::success($status, 'Statut crรฉรฉ', 201); + } + + /** + * PUT /statuses/{id} + */ + public function update(): void + { + $this->requireAuth(); + + $id = (int) $this->request->getParam('id'); + $this->findOrFail($id); + + $data = $this->validate([ + 'code' => 'int', + 'name' => 'min:1|max:50', + 'color' => 'max:7', + 'position' => 'int', + ]); + + $db = Database::getInstance(); + + $fields = []; + $params = ['id' => $id]; + + if (isset($data['code'])) { + $fields[] = 'code = :code'; + $params['code'] = $data['code']; + } + + if (isset($data['name'])) { + $fields[] = 'name = :name'; + $params['name'] = $data['name']; + } + + if (isset($data['color'])) { + $fields[] = 'color = :color'; + $params['color'] = $data['color']; + } + + if (isset($data['position'])) { + $fields[] = 'position = :position'; + $params['position'] = $data['position']; + } + + if (!empty($fields)) { + $sql = 'UPDATE statuses SET ' . implode(', ', $fields) . ' WHERE id = :id'; + $stmt = $db->prepare($sql); + $stmt->execute($params); + } + + $status = $this->findOrFail($id); + + Response::success($status, 'Statut mis ร  jour'); + } + + /** + * DELETE /statuses/{id} + */ + public function destroy(): void + { + $this->requireAuth(); + + $id = (int) $this->request->getParam('id'); + $status = $this->findOrFail($id); + + $db = Database::getInstance(); + + // Vรฉrifier qu'aucune tรขche n'utilise ce statut + $stmt = $db->prepare('SELECT COUNT(*) as count FROM tasks WHERE status_id = :id'); + $stmt->execute(['id' => $id]); + $count = (int) $stmt->fetch()['count']; + + if ($count > 0) { + Response::error("Impossible de supprimer : {$count} tรขche(s) utilisent ce statut", 409); + } + + $stmt = $db->prepare('DELETE FROM statuses WHERE id = :id'); + $stmt->execute(['id' => $id]); + + Response::success(null, 'Statut supprimรฉ'); + } + + /** + * Trouver un statut ou retourner 404 + */ + private function findOrFail(int $id): array + { + $db = Database::getInstance(); + + $stmt = $db->prepare(' + SELECT * FROM statuses + WHERE id = :id AND user_id = :user_id + '); + + $stmt->execute([ + 'id' => $id, + 'user_id' => $this->getUserId(), + ]); + + $status = $stmt->fetch(); + + if (!$status) { + Response::notFound('Statut non trouvรฉ'); + } + + return $status; + } + + /** + * Vรฉrifier qu'un projet appartient ร  l'utilisateur + */ + private function verifyProject(int $projectId): void + { + $db = Database::getInstance(); + + $stmt = $db->prepare('SELECT id FROM projects WHERE id = :id AND user_id = :user_id'); + $stmt->execute(['id' => $projectId, 'user_id' => $this->getUserId()]); + + if (!$stmt->fetch()) { + Response::error('Projet invalide', 422); + } + } +} diff --git a/clients/prokov/api/controllers/TagController.php b/clients/prokov/api/controllers/TagController.php new file mode 100644 index 0000000..d231ca0 --- /dev/null +++ b/clients/prokov/api/controllers/TagController.php @@ -0,0 +1,235 @@ +requireAuth(); + + $db = Database::getInstance(); + + $stmt = $db->prepare(' + SELECT t.*, + (SELECT COUNT(*) FROM project_tags pt WHERE pt.tag_id = t.id) as project_count, + (SELECT COUNT(*) FROM task_tags tt WHERE tt.tag_id = t.id) as task_count + FROM tags t + WHERE t.user_id = :user_id + ORDER BY t.name ASC + '); + + $stmt->execute(['user_id' => $this->getUserId()]); + $tags = $stmt->fetchAll(); + + Response::success($tags); + } + + /** + * GET /tags/{id} + */ + public function show(): void + { + $this->requireAuth(); + + $id = (int) $this->request->getParam('id'); + $tag = $this->findOrFail($id); + + // Rรฉcupรฉrer les projets associรฉs + $tag['projects'] = $this->getTagProjects($id); + + // Rรฉcupรฉrer les tรขches associรฉes + $tag['tasks'] = $this->getTagTasks($id); + + Response::success($tag); + } + + /** + * POST /tags + */ + public function store(): void + { + $this->requireAuth(); + + $data = $this->validate([ + 'name' => 'required|min:1|max:50', + 'color' => 'max:7', + ]); + + $db = Database::getInstance(); + + // Vรฉrifier unicitรฉ du nom pour cet utilisateur + $stmt = $db->prepare('SELECT id FROM tags WHERE user_id = :user_id AND name = :name'); + $stmt->execute(['user_id' => $this->getUserId(), 'name' => $data['name']]); + + if ($stmt->fetch()) { + Response::error('Ce tag existe dรฉjร ', 409); + } + + $stmt = $db->prepare(' + INSERT INTO tags (user_id, name, color) + VALUES (:user_id, :name, :color) + '); + + $stmt->execute([ + 'user_id' => $this->getUserId(), + 'name' => $data['name'], + 'color' => $data['color'] ?? '#3B82F6', + ]); + + $tagId = (int) $db->lastInsertId(); + $tag = $this->findOrFail($tagId); + + Response::success($tag, 'Tag crรฉรฉ', 201); + } + + /** + * PUT /tags/{id} + */ + public function update(): void + { + $this->requireAuth(); + + $id = (int) $this->request->getParam('id'); + $this->findOrFail($id); + + $data = $this->validate([ + 'name' => 'min:1|max:50', + 'color' => 'max:7', + ]); + + $db = Database::getInstance(); + + // Vรฉrifier unicitรฉ du nom si modifiรฉ + if (!empty($data['name'])) { + $stmt = $db->prepare(' + SELECT id FROM tags + WHERE user_id = :user_id AND name = :name AND id != :id + '); + $stmt->execute([ + 'user_id' => $this->getUserId(), + 'name' => $data['name'], + 'id' => $id, + ]); + + if ($stmt->fetch()) { + Response::error('Ce tag existe dรฉjร ', 409); + } + } + + $fields = []; + $params = ['id' => $id]; + + if (isset($data['name'])) { + $fields[] = 'name = :name'; + $params['name'] = $data['name']; + } + + if (isset($data['color'])) { + $fields[] = 'color = :color'; + $params['color'] = $data['color']; + } + + if (!empty($fields)) { + $sql = 'UPDATE tags SET ' . implode(', ', $fields) . ' WHERE id = :id'; + $stmt = $db->prepare($sql); + $stmt->execute($params); + } + + $tag = $this->findOrFail($id); + + Response::success($tag, 'Tag mis ร  jour'); + } + + /** + * DELETE /tags/{id} + */ + public function destroy(): void + { + $this->requireAuth(); + + $id = (int) $this->request->getParam('id'); + $this->findOrFail($id); + + $db = Database::getInstance(); + + // Les associations seront supprimรฉes en cascade (FK) + $stmt = $db->prepare('DELETE FROM tags WHERE id = :id'); + $stmt->execute(['id' => $id]); + + Response::success(null, 'Tag supprimรฉ'); + } + + /** + * Trouver un tag ou retourner 404 + */ + private function findOrFail(int $id): array + { + $db = Database::getInstance(); + + $stmt = $db->prepare(' + SELECT * FROM tags + WHERE id = :id AND user_id = :user_id + '); + + $stmt->execute([ + 'id' => $id, + 'user_id' => $this->getUserId(), + ]); + + $tag = $stmt->fetch(); + + if (!$tag) { + Response::notFound('Tag non trouvรฉ'); + } + + return $tag; + } + + /** + * Rรฉcupรฉrer les projets associรฉs ร  un tag + */ + private function getTagProjects(int $tagId): array + { + $db = Database::getInstance(); + + $stmt = $db->prepare(' + SELECT p.id, p.name + FROM projects p + JOIN project_tags pt ON p.id = pt.project_id + WHERE pt.tag_id = :tag_id + ORDER BY p.name ASC + '); + + $stmt->execute(['tag_id' => $tagId]); + + return $stmt->fetchAll(); + } + + /** + * Rรฉcupรฉrer les tรขches associรฉes ร  un tag + */ + private function getTagTasks(int $tagId): array + { + $db = Database::getInstance(); + + $stmt = $db->prepare(' + SELECT t.id, t.title, t.status_id, s.name as status_name + FROM tasks t + JOIN task_tags tt ON t.id = tt.task_id + LEFT JOIN statuses s ON t.status_id = s.id + WHERE tt.tag_id = :tag_id + ORDER BY t.created_at DESC + '); + + $stmt->execute(['tag_id' => $tagId]); + + return $stmt->fetchAll(); + } +} diff --git a/clients/prokov/api/controllers/TaskController.php b/clients/prokov/api/controllers/TaskController.php new file mode 100644 index 0000000..7a29cee --- /dev/null +++ b/clients/prokov/api/controllers/TaskController.php @@ -0,0 +1,399 @@ +requireAuth(); + + $db = Database::getInstance(); + + $where = ['t.user_id = :user_id']; + $params = ['user_id' => $this->getUserId()]; + + // Filtre par projet + $projectId = $this->request->get('project_id'); + if ($projectId !== null) { + $where[] = 't.project_id = :project_id'; + $params['project_id'] = (int) $projectId; + } + + // Filtre par statut + $statusId = $this->request->get('status_id'); + if ($statusId !== null) { + $where[] = 't.status_id = :status_id'; + $params['status_id'] = (int) $statusId; + } + + // Filtre par date de dรฉbut + $dateStart = $this->request->get('date_start'); + if ($dateStart !== null) { + $where[] = 't.date_start >= :date_start'; + $params['date_start'] = $dateStart; + } + + // Filtre par date de fin + $dateEnd = $this->request->get('date_end'); + if ($dateEnd !== null) { + $where[] = 't.date_end <= :date_end'; + $params['date_end'] = $dateEnd; + } + + $sql = ' + SELECT t.*, + p.name as project_name, + s.name as status_name, + s.color as status_color, + GROUP_CONCAT(tg.id) as tag_ids, + GROUP_CONCAT(tg.name) as tag_names, + GROUP_CONCAT(tg.color) as tag_colors + FROM tasks t + LEFT JOIN projects p ON t.project_id = p.id + LEFT JOIN statuses s ON t.status_id = s.id + LEFT JOIN task_tags tt ON t.id = tt.task_id + LEFT JOIN tags tg ON tt.tag_id = tg.id + WHERE ' . implode(' AND ', $where) . ' + GROUP BY t.id + ORDER BY t.position ASC, t.priority DESC, t.created_at DESC + '; + + $stmt = $db->prepare($sql); + $stmt->execute($params); + $tasks = $stmt->fetchAll(); + + // Filtre par tag (aprรจs GROUP BY) + $tagId = $this->request->get('tag_id'); + + // Parser les tags + foreach ($tasks as &$task) { + $task['tags'] = $this->parseTags($task); + unset($task['tag_ids'], $task['tag_names'], $task['tag_colors']); + } + + // Appliquer filtre tag si nรฉcessaire + if ($tagId !== null) { + $tagId = (int) $tagId; + $tasks = array_filter($tasks, function ($task) use ($tagId) { + foreach ($task['tags'] as $tag) { + if ($tag['id'] === $tagId) { + return true; + } + } + return false; + }); + $tasks = array_values($tasks); + } + + Response::success($tasks); + } + + /** + * GET /tasks/{id} + */ + public function show(): void + { + $this->requireAuth(); + + $id = (int) $this->request->getParam('id'); + $task = $this->findOrFail($id); + + $task['tags'] = $this->getTaskTags($id); + + Response::success($task); + } + + /** + * POST /tasks + */ + public function store(): void + { + $this->requireAuth(); + + $data = $this->validate([ + 'project_id' => 'required|int', + 'status_id' => 'required|int', + 'title' => 'required|min:1|max:255', + 'description' => 'max:65535', + 'priority' => 'int', + 'date_start' => 'max:10', + 'date_end' => 'max:10', + 'time_estimated' => 'int', + 'time_spent' => 'int', + 'billing' => 'numeric', + 'position' => 'int', + ]); + + // Vรฉrifier que le projet appartient ร  l'utilisateur + $this->verifyProject((int) $data['project_id']); + + // Vรฉrifier que le statut appartient ร  l'utilisateur + $this->verifyStatus((int) $data['status_id']); + + $db = Database::getInstance(); + + $stmt = $db->prepare(' + INSERT INTO tasks (user_id, project_id, status_id, title, description, priority, + date_start, date_end, time_estimated, time_spent, billing, position) + VALUES (:user_id, :project_id, :status_id, :title, :description, :priority, + :date_start, :date_end, :time_estimated, :time_spent, :billing, :position) + '); + + $stmt->execute([ + 'user_id' => $this->getUserId(), + 'project_id' => $data['project_id'], + 'status_id' => $data['status_id'], + 'title' => $data['title'], + 'description' => $data['description'] ?? null, + 'priority' => $data['priority'] ?? 5, + 'date_start' => $data['date_start'] ?: null, + 'date_end' => $data['date_end'] ?: null, + 'time_estimated' => $data['time_estimated'] ?? 0, + 'time_spent' => $data['time_spent'] ?? 0, + 'billing' => $data['billing'] ?? 0, + 'position' => $data['position'] ?? 0, + ]); + + $taskId = (int) $db->lastInsertId(); + + // Gรฉrer les tags si fournis + $tags = $this->request->get('tags'); + if (is_array($tags)) { + $this->syncTags($taskId, $tags); + } + + $task = $this->findOrFail($taskId); + $task['tags'] = $this->getTaskTags($taskId); + + Response::success($task, 'Tรขche crรฉรฉe', 201); + } + + /** + * PUT /tasks/{id} + */ + public function update(): void + { + $this->requireAuth(); + + $id = (int) $this->request->getParam('id'); + $this->findOrFail($id); + + $data = $this->validate([ + 'project_id' => 'int', + 'status_id' => 'int', + 'title' => 'min:1|max:255', + 'description' => 'max:65535', + 'priority' => 'int', + 'date_start' => 'max:10', + 'date_end' => 'max:10', + 'time_estimated' => 'int', + 'time_spent' => 'int', + 'billing' => 'numeric', + 'position' => 'int', + ]); + + if (!empty($data['project_id'])) { + $this->verifyProject((int) $data['project_id']); + } + + if (!empty($data['status_id'])) { + $this->verifyStatus((int) $data['status_id']); + } + + $db = Database::getInstance(); + + $fields = []; + $params = ['id' => $id]; + + $allowedFields = [ + 'project_id', 'status_id', 'title', 'description', 'priority', + 'date_start', 'date_end', 'time_estimated', 'time_spent', 'billing', 'position' + ]; + + foreach ($allowedFields as $field) { + if (array_key_exists($field, $data)) { + $fields[] = "{$field} = :{$field}"; + $value = $data[$field]; + // Convertir les chaรฎnes vides en null pour les dates + if (in_array($field, ['date_start', 'date_end']) && $value === '') { + $value = null; + } + $params[$field] = $value; + } + } + + if (!empty($fields)) { + $sql = 'UPDATE tasks SET ' . implode(', ', $fields) . ' WHERE id = :id'; + $stmt = $db->prepare($sql); + $stmt->execute($params); + } + + // Gรฉrer les tags si fournis + $tags = $this->request->get('tags'); + if (is_array($tags)) { + $this->syncTags($id, $tags); + } + + $task = $this->findOrFail($id); + $task['tags'] = $this->getTaskTags($id); + + Response::success($task, 'Tรขche mise ร  jour'); + } + + /** + * DELETE /tasks/{id} + */ + public function destroy(): void + { + $this->requireAuth(); + + $id = (int) $this->request->getParam('id'); + $this->findOrFail($id); + + $db = Database::getInstance(); + + $stmt = $db->prepare('DELETE FROM tasks WHERE id = :id'); + $stmt->execute(['id' => $id]); + + Response::success(null, 'Tรขche supprimรฉe'); + } + + /** + * Trouver une tรขche ou retourner 404 + */ + private function findOrFail(int $id): array + { + $db = Database::getInstance(); + + $stmt = $db->prepare(' + SELECT t.*, p.name as project_name, s.name as status_name, s.color as status_color + FROM tasks t + LEFT JOIN projects p ON t.project_id = p.id + LEFT JOIN statuses s ON t.status_id = s.id + WHERE t.id = :id AND t.user_id = :user_id + '); + + $stmt->execute([ + 'id' => $id, + 'user_id' => $this->getUserId(), + ]); + + $task = $stmt->fetch(); + + if (!$task) { + Response::notFound('Tรขche non trouvรฉe'); + } + + return $task; + } + + /** + * Vรฉrifier qu'un projet appartient ร  l'utilisateur + */ + private function verifyProject(int $projectId): void + { + $db = Database::getInstance(); + + $stmt = $db->prepare('SELECT id FROM projects WHERE id = :id AND user_id = :user_id'); + $stmt->execute(['id' => $projectId, 'user_id' => $this->getUserId()]); + + if (!$stmt->fetch()) { + Response::error('Projet invalide', 422); + } + } + + /** + * Vรฉrifier qu'un statut appartient ร  l'utilisateur + */ + private function verifyStatus(int $statusId): void + { + $db = Database::getInstance(); + + $stmt = $db->prepare('SELECT id FROM statuses WHERE id = :id AND user_id = :user_id'); + $stmt->execute(['id' => $statusId, 'user_id' => $this->getUserId()]); + + if (!$stmt->fetch()) { + Response::error('Statut invalide', 422); + } + } + + /** + * Parser les tags depuis le GROUP_CONCAT + */ + private function parseTags(array $task): array + { + $tags = []; + if (!empty($task['tag_ids'])) { + $ids = explode(',', $task['tag_ids']); + $names = explode(',', $task['tag_names']); + $colors = explode(',', $task['tag_colors']); + foreach ($ids as $i => $tagId) { + $tags[] = [ + 'id' => (int) $tagId, + 'name' => $names[$i] ?? '', + 'color' => $colors[$i] ?? '#3B82F6', + ]; + } + } + return $tags; + } + + /** + * Rรฉcupรฉrer les tags d'une tรขche + */ + private function getTaskTags(int $taskId): array + { + $db = Database::getInstance(); + + $stmt = $db->prepare(' + SELECT t.id, t.name, t.color + FROM tags t + JOIN task_tags tt ON t.id = tt.tag_id + WHERE tt.task_id = :task_id + '); + + $stmt->execute(['task_id' => $taskId]); + + return $stmt->fetchAll(); + } + + /** + * Synchroniser les tags d'une tรขche + */ + private function syncTags(int $taskId, array $tagIds): void + { + $db = Database::getInstance(); + + $stmt = $db->prepare('DELETE FROM task_tags WHERE task_id = :task_id'); + $stmt->execute(['task_id' => $taskId]); + + if (!empty($tagIds)) { + $stmt = $db->prepare(' + INSERT INTO task_tags (task_id, tag_id) + SELECT :task_id, id FROM tags + WHERE id = :tag_id AND user_id = :user_id + '); + + foreach ($tagIds as $tagId) { + $stmt->execute([ + 'task_id' => $taskId, + 'tag_id' => (int) $tagId, + 'user_id' => $this->getUserId(), + ]); + } + } + } +} diff --git a/clients/prokov/api/core/Controller.php b/clients/prokov/api/core/Controller.php new file mode 100644 index 0000000..38c8b1c --- /dev/null +++ b/clients/prokov/api/core/Controller.php @@ -0,0 +1,108 @@ +request = $request; + } + + /** + * Requiert une authentification valide + */ + protected function requireAuth(): void + { + $sessionId = $this->request->getSessionId(); + + if (empty($sessionId)) { + Response::unauthorized('Session ID required'); + } + + $user = Session::validate($sessionId); + + if ($user === null) { + Response::unauthorized('Invalid or expired session'); + } + + $this->user = $user; + } + + /** + * Retourne l'ID de l'utilisateur authentifiรฉ + */ + protected function getUserId(): int + { + return $this->user['id']; + } + + /** + * Valide les champs requis dans le body + */ + protected function validate(array $rules): array + { + $body = $this->request->getBody(); + $errors = []; + $data = []; + + foreach ($rules as $field => $rule) { + $value = $body[$field] ?? null; + $ruleList = explode('|', $rule); + + foreach ($ruleList as $r) { + if ($r === 'required' && ($value === null || $value === '')) { + $errors[$field] = "Le champ {$field} est requis"; + break; + } + + if ($r === 'email' && $value !== null && !filter_var($value, FILTER_VALIDATE_EMAIL)) { + $errors[$field] = "Le champ {$field} doit รชtre un email valide"; + break; + } + + if (str_starts_with($r, 'min:')) { + $min = (int) substr($r, 4); + if ($value !== null && strlen($value) < $min) { + $errors[$field] = "Le champ {$field} doit contenir au moins {$min} caractรจres"; + break; + } + } + + if (str_starts_with($r, 'max:')) { + $max = (int) substr($r, 4); + if ($value !== null && strlen($value) > $max) { + $errors[$field] = "Le champ {$field} doit contenir au maximum {$max} caractรจres"; + break; + } + } + + if ($r === 'int' && $value !== null && !is_numeric($value)) { + $errors[$field] = "Le champ {$field} doit รชtre un nombre entier"; + break; + } + + if ($r === 'numeric' && $value !== null && !is_numeric($value)) { + $errors[$field] = "Le champ {$field} doit รชtre un nombre"; + break; + } + } + + if (!isset($errors[$field])) { + $data[$field] = $value; + } + } + + if (!empty($errors)) { + Response::error('Validation failed', 422, $errors); + } + + return $data; + } +} diff --git a/clients/prokov/api/core/Request.php b/clients/prokov/api/core/Request.php new file mode 100644 index 0000000..064bbd9 --- /dev/null +++ b/clients/prokov/api/core/Request.php @@ -0,0 +1,112 @@ +method = $_SERVER['REQUEST_METHOD']; + $this->uri = $this->parseUri(); + $this->headers = $this->parseHeaders(); + $this->body = $this->parseBody(); + } + + private function parseUri(): string + { + $uri = $_SERVER['REQUEST_URI'] ?? '/'; + + // Retirer le query string + if (($pos = strpos($uri, '?')) !== false) { + $uri = substr($uri, 0, $pos); + } + + // Retirer le prรฉfixe /api si prรฉsent + $uri = preg_replace('#^/api#', '', $uri); + + return '/' . trim($uri, '/'); + } + + private function parseHeaders(): array + { + $headers = []; + foreach ($_SERVER as $key => $value) { + if (str_starts_with($key, 'HTTP_')) { + $name = str_replace('_', '-', substr($key, 5)); + $headers[$name] = $value; + } + } + return $headers; + } + + private function parseBody(): array + { + if (in_array($this->method, ['POST', 'PUT', 'PATCH'])) { + $input = file_get_contents('php://input'); + if (!empty($input)) { + $decoded = json_decode($input, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + return $decoded; + } + } + // Fallback sur $_POST ou array vide + return $_POST ?: []; + } + return []; + } + + public function getMethod(): string + { + return $this->method; + } + + public function getUri(): string + { + return $this->uri; + } + + public function getHeader(string $name): ?string + { + $name = strtoupper(str_replace('-', '_', $name)); + return $this->headers[$name] ?? null; + } + + public function getSessionId(): ?string + { + return $this->getHeader('X-SESSION-ID'); + } + + public function getBody(): array + { + return $this->body; + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->body[$key] ?? $_GET[$key] ?? $default; + } + + public function setParams(array $params): void + { + $this->params = $params; + } + + public function getParam(string $key, mixed $default = null): mixed + { + return $this->params[$key] ?? $default; + } + + public function getParams(): array + { + return $this->params; + } +} diff --git a/clients/prokov/api/core/Response.php b/clients/prokov/api/core/Response.php new file mode 100644 index 0000000..c3f9b16 --- /dev/null +++ b/clients/prokov/api/core/Response.php @@ -0,0 +1,49 @@ + true, + 'message' => $message, + 'data' => $data, + ], $code); + } + + public static function error(string $message, int $code = 400, mixed $errors = null): void + { + $response = [ + 'success' => false, + 'message' => $message, + ]; + + if ($errors !== null) { + $response['errors'] = $errors; + } + + self::json($response, $code); + } + + public static function notFound(string $message = 'Resource not found'): void + { + self::error($message, 404); + } + + public static function unauthorized(string $message = 'Unauthorized'): void + { + self::error($message, 401); + } +} diff --git a/clients/prokov/api/core/Router.php b/clients/prokov/api/core/Router.php new file mode 100644 index 0000000..5f20862 --- /dev/null +++ b/clients/prokov/api/core/Router.php @@ -0,0 +1,145 @@ +request = new Request(); + $this->registerRoutes(); + } + + private function registerRoutes(): void + { + // Auth (routes publiques) + $this->post('/auth/register', 'AuthController@register'); + $this->post('/auth/login', 'AuthController@login'); + $this->post('/auth/logout', 'AuthController@logout'); + $this->get('/auth/me', 'AuthController@me'); + + // Projects + $this->get('/projects', 'ProjectController@index'); + $this->get('/projects/{id}', 'ProjectController@show'); + $this->post('/projects', 'ProjectController@store'); + $this->put('/projects/{id}', 'ProjectController@update'); + $this->delete('/projects/{id}', 'ProjectController@destroy'); + + // Tasks + $this->get('/tasks', 'TaskController@index'); + $this->get('/tasks/{id}', 'TaskController@show'); + $this->post('/tasks', 'TaskController@store'); + $this->put('/tasks/{id}', 'TaskController@update'); + $this->delete('/tasks/{id}', 'TaskController@destroy'); + + // Tags + $this->get('/tags', 'TagController@index'); + $this->get('/tags/{id}', 'TagController@show'); + $this->post('/tags', 'TagController@store'); + $this->put('/tags/{id}', 'TagController@update'); + $this->delete('/tags/{id}', 'TagController@destroy'); + + // Statuses + $this->get('/statuses', 'StatusController@index'); + $this->get('/statuses/{id}', 'StatusController@show'); + $this->post('/statuses', 'StatusController@store'); + $this->put('/statuses/{id}', 'StatusController@update'); + $this->delete('/statuses/{id}', 'StatusController@destroy'); + } + + private function addRoute(string $method, string $path, string $handler): void + { + $this->routes[] = [ + 'method' => $method, + 'path' => $path, + 'handler' => $handler, + ]; + } + + public function get(string $path, string $handler): void + { + $this->addRoute('GET', $path, $handler); + } + + public function post(string $path, string $handler): void + { + $this->addRoute('POST', $path, $handler); + } + + public function put(string $path, string $handler): void + { + $this->addRoute('PUT', $path, $handler); + } + + public function delete(string $path, string $handler): void + { + $this->addRoute('DELETE', $path, $handler); + } + + public function dispatch(): void + { + $method = $this->request->getMethod(); + $uri = $this->request->getUri(); + + foreach ($this->routes as $route) { + if ($route['method'] !== $method) { + continue; + } + + $params = $this->matchRoute($route['path'], $uri); + if ($params !== false) { + $this->request->setParams($params); + $this->callHandler($route['handler']); + return; + } + } + + Response::notFound('Route not found'); + } + + private function matchRoute(string $routePath, string $uri): array|false + { + // Convertir /projects/{id} en regex /projects/([^/]+) + $pattern = preg_replace('#\{(\w+)\}#', '([^/]+)', $routePath); + $pattern = '#^' . $pattern . '$#'; + + if (preg_match($pattern, $uri, $matches)) { + array_shift($matches); // Retirer le match complet + + // Extraire les noms des paramรจtres + preg_match_all('#\{(\w+)\}#', $routePath, $paramNames); + $params = []; + + foreach ($paramNames[1] as $index => $name) { + $params[$name] = $matches[$index] ?? null; + } + + return $params; + } + + return false; + } + + private function callHandler(string $handler): void + { + [$controllerName, $methodName] = explode('@', $handler); + + if (!class_exists($controllerName)) { + Response::error("Controller {$controllerName} not found", 500); + } + + $controller = new $controllerName($this->request); + + if (!method_exists($controller, $methodName)) { + Response::error("Method {$methodName} not found", 500); + } + + $controller->$methodName(); + } +} diff --git a/clients/prokov/api/core/Session.php b/clients/prokov/api/core/Session.php new file mode 100644 index 0000000..014809e --- /dev/null +++ b/clients/prokov/api/core/Session.php @@ -0,0 +1,162 @@ +prepare(' + INSERT INTO sessions (id, user_id, ip_address, user_agent, expires_at) + VALUES (:id, :user_id, :ip_address, :user_agent, :expires_at) + '); + + $stmt->execute([ + 'id' => $sessionId, + 'user_id' => $userId, + 'ip_address' => $ipAddress ?? self::getClientIp(), + 'user_agent' => $userAgent ?? $_SERVER['HTTP_USER_AGENT'] ?? null, + 'expires_at' => $expiresAt, + ]); + + return $sessionId; + } + + /** + * Valider une session et retourner l'utilisateur + */ + public static function validate(string $sessionId): ?array + { + if (self::$currentSession !== null && self::$currentSession['id'] === $sessionId) { + return self::$currentUser; + } + + $db = Database::getInstance(); + + $stmt = $db->prepare(' + SELECT s.*, u.id as user_id, u.email, u.name + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.id = :id AND s.expires_at > NOW() + '); + + $stmt->execute(['id' => $sessionId]); + $result = $stmt->fetch(); + + if (!$result) { + return null; + } + + self::$currentSession = [ + 'id' => $result['id'], + 'user_id' => $result['user_id'], + 'expires_at' => $result['expires_at'], + ]; + + self::$currentUser = [ + 'id' => $result['user_id'], + 'email' => $result['email'], + 'name' => $result['name'], + ]; + + return self::$currentUser; + } + + /** + * Dรฉtruire une session + */ + public static function destroy(string $sessionId): bool + { + $db = Database::getInstance(); + + $stmt = $db->prepare('DELETE FROM sessions WHERE id = :id'); + $stmt->execute(['id' => $sessionId]); + + self::$currentSession = null; + self::$currentUser = null; + + return $stmt->rowCount() > 0; + } + + /** + * Nettoyer les sessions expirรฉes + */ + public static function cleanup(): int + { + $db = Database::getInstance(); + + $stmt = $db->prepare('DELETE FROM sessions WHERE expires_at < NOW()'); + $stmt->execute(); + + return $stmt->rowCount(); + } + + /** + * Prolonger une session + */ + public static function extend(string $sessionId): bool + { + $db = Database::getInstance(); + $expiresAt = date('Y-m-d H:i:s', time() + SESSION_LIFETIME); + + $stmt = $db->prepare(' + UPDATE sessions SET expires_at = :expires_at WHERE id = :id + '); + + $stmt->execute([ + 'id' => $sessionId, + 'expires_at' => $expiresAt, + ]); + + return $stmt->rowCount() > 0; + } + + /** + * Obtenir l'utilisateur courant (depuis le cache) + */ + public static function getCurrentUser(): ?array + { + return self::$currentUser; + } +} diff --git a/clients/prokov/api/deploy-api.sh b/clients/prokov/api/deploy-api.sh new file mode 100755 index 0000000..13f8f12 --- /dev/null +++ b/clients/prokov/api/deploy-api.sh @@ -0,0 +1,147 @@ +#!/bin/bash + +# Script de dรฉploiement pour PROKOV API +# Version: 1.0 (12 dรฉcembre 2025) +# Auteur: Pierre (avec l'aide de Claude) + +set -euo pipefail + +ENV=DEV +JUMP_USER="root" +JUMP_HOST="195.154.80.116" +JUMP_PORT="22" +JUMP_KEY="/home/pierre/.ssh/id_rsa_mbpi" +INCUS_PROJECT=default +INCUS_CONTAINER=dva-front + +# Paramรจtres du container Incus +CONTAINER_USER=root +CONTAINER_IP="13.23.33.42" + +# Paramรจtres de dรฉploiement +FINAL_PATH="/var/www/prokov/api" +FINAL_OWNER="nginx" +FINAL_GROUP="nginx" +FINAL_OWNER_LOGS="nobody" + +# Couleurs pour les messages +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Fonction pour afficher les messages d'รฉtape +echo_step() { + echo -e "${GREEN}==>${NC} $1" +} + +# Fonction pour afficher les informations +echo_info() { + echo -e "${BLUE}Info:${NC} $1" +} + +# Fonction pour afficher les avertissements +echo_warning() { + echo -e "${YELLOW}Warning:${NC} $1" +} + +# Fonction pour afficher les erreurs +echo_error() { + echo -e "${RED}Error:${NC} $1" + exit 1 +} + +# Vรฉrification de l'environnement +echo_step "Verifying environment..." +echo_info "Deploying PROKOV API to $ENV environment" +echo_info "Container: $INCUS_CONTAINER (IP: $CONTAINER_IP)" +echo_info "Target path: $FINAL_PATH" + +# Vรฉrification des fichiers requis +if [ ! -f "public/index.php" ]; then + echo_error "public/index.php missing - are you in the api directory?" +fi + +if [ ! -d "core" ] || [ ! -d "controllers" ]; then + echo_error "API structure incomplete (core/ or controllers/ missing)" +fi + +# ร‰tape 0: Dรฉfinir le nom de l'archive +ARCHIVE_NAME="prokov-api-${ENV}-$(date +%s).tar.gz" +ARCHIVE_PATH="/tmp/${ARCHIVE_NAME}" +echo_info "Archive name will be: $ARCHIVE_NAME" + +# ร‰tape 1: Crรฉer une archive du projet +echo_step "Creating project archive..." +tar --exclude='.git' \ + --exclude='.gitignore' \ + --exclude='.vscode' \ + --exclude='logs' \ + --exclude='*.template' \ + --exclude='*.sh' \ + --exclude='.env' \ + --exclude='*.log' \ + --exclude='.DS_Store' \ + --exclude='README.md' \ + --exclude="*.tar.gz" \ + --no-xattrs \ + -czf "${ARCHIVE_PATH}" . || echo_error "Failed to create archive" + +# Vรฉrifier la taille de l'archive +ARCHIVE_SIZE=$(du -h "${ARCHIVE_PATH}" | cut -f1) + +SSH_JUMP_CMD="ssh -i ${JUMP_KEY} -p ${JUMP_PORT} ${JUMP_USER}@${JUMP_HOST}" + +# ร‰tape 2: Copier l'archive vers le serveur de saut (IN3) +echo_step "Copying archive to jump server (IN3)..." +echo_info "Archive size: $ARCHIVE_SIZE" +scp -i "${JUMP_KEY}" -P "${JUMP_PORT}" "${ARCHIVE_PATH}" "${JUMP_USER}@${JUMP_HOST}:/tmp/${ARCHIVE_NAME}" || echo_error "Failed to copy archive to jump server" + +# ร‰tape 3: Exรฉcuter les commandes sur IN3 pour dรฉployer dans le container Incus dva-front +echo_step "Deploying to Incus container ($INCUS_CONTAINER)..." +$SSH_JUMP_CMD " + set -euo pipefail + + echo 'โœ… Passage au projet Incus...' + incus project switch ${INCUS_PROJECT} || exit 1 + + echo '๐Ÿ“ฆ Poussรฉe de l archive dans le conteneur...' + incus file push /tmp/${ARCHIVE_NAME} ${INCUS_CONTAINER}/tmp/${ARCHIVE_NAME} || exit 1 + + echo '๐Ÿ“ Prรฉparation du dossier final...' + incus exec ${INCUS_CONTAINER} -- mkdir -p ${FINAL_PATH} || exit 1 + incus exec ${INCUS_CONTAINER} -- rm -rf ${FINAL_PATH}/* || exit 1 + incus exec ${INCUS_CONTAINER} -- tar -xzf /tmp/${ARCHIVE_NAME} -C ${FINAL_PATH}/ || exit 1 + + echo '๐Ÿ”ง Rรฉglage des permissions...' + incus exec ${INCUS_CONTAINER} -- mkdir -p ${FINAL_PATH}/logs || exit 1 + incus exec ${INCUS_CONTAINER} -- chown -R ${FINAL_OWNER}:${FINAL_GROUP} ${FINAL_PATH} || exit 1 + incus exec ${INCUS_CONTAINER} -- find ${FINAL_PATH} -type d -exec chmod 755 {} \; || exit 1 + incus exec ${INCUS_CONTAINER} -- find ${FINAL_PATH} -type f -exec chmod 644 {} \; || exit 1 + + # Permissions spรฉciales pour le dossier logs + incus exec ${INCUS_CONTAINER} -- chown -R ${FINAL_OWNER}:${FINAL_OWNER_LOGS} ${FINAL_PATH}/logs || exit 1 + incus exec ${INCUS_CONTAINER} -- chmod -R 775 ${FINAL_PATH}/logs || exit 1 + + echo '๐Ÿงน Nettoyage...' + incus exec ${INCUS_CONTAINER} -- rm -f /tmp/${ARCHIVE_NAME} || exit 1 + rm -f /tmp/${ARCHIVE_NAME} || exit 1 +" + +# Nettoyage local +rm -f "${ARCHIVE_PATH}" + +# Rรฉsumรฉ final +echo_step "Deployment completed successfully." +echo "" +echo_info "PROKOV API deployed to $ENV environment" +echo_info " Host: IN3 ($JUMP_HOST)" +echo_info " Container: $INCUS_CONTAINER ($CONTAINER_IP)" +echo_info " Path: $FINAL_PATH" +echo_info " Deployment time: $(date)" +echo "" +echo_info "API should be accessible at: https://prokov.unikoffice.com/api/" + +# Journaliser le dรฉploiement +echo "$(date '+%Y-%m-%d %H:%M:%S') - PROKOV API deployed to ${ENV} (${INCUS_CONTAINER}:${FINAL_PATH})" >> ~/.prokov_deploy_history diff --git a/clients/prokov/api/models/User.php b/clients/prokov/api/models/User.php new file mode 100644 index 0000000..c40377f --- /dev/null +++ b/clients/prokov/api/models/User.php @@ -0,0 +1,74 @@ +prepare(' + SELECT id, email, name, created_at, updated_at + FROM users + WHERE id = :id + '); + + $stmt->execute(['id' => $id]); + $user = $stmt->fetch(); + + return $user ?: null; + } + + public static function findByEmail(string $email): ?array + { + $db = Database::getInstance(); + + $stmt = $db->prepare(' + SELECT id, email, name, password, created_at, updated_at + FROM users + WHERE email = :email + '); + + $stmt->execute(['email' => $email]); + $user = $stmt->fetch(); + + return $user ?: null; + } + + public static function update(int $id, array $data): bool + { + $db = Database::getInstance(); + + $fields = []; + $params = ['id' => $id]; + + if (isset($data['name'])) { + $fields[] = 'name = :name'; + $params['name'] = $data['name']; + } + + if (isset($data['email'])) { + $fields[] = 'email = :email'; + $params['email'] = $data['email']; + } + + if (isset($data['password'])) { + $fields[] = 'password = :password'; + $params['password'] = password_hash($data['password'], PASSWORD_DEFAULT); + } + + if (empty($fields)) { + return false; + } + + $sql = 'UPDATE users SET ' . implode(', ', $fields) . ' WHERE id = :id'; + $stmt = $db->prepare($sql); + $stmt->execute($params); + + return $stmt->rowCount() > 0; + } +} diff --git a/clients/prokov/api/public/index.php b/clients/prokov/api/public/index.php new file mode 100644 index 0000000..f1168da --- /dev/null +++ b/clients/prokov/api/public/index.php @@ -0,0 +1,47 @@ +dispatch(); diff --git a/cmd/sogoms/db/main.go b/cmd/sogoms/db/main.go new file mode 100755 index 0000000..d6e610f --- /dev/null +++ b/cmd/sogoms/db/main.go @@ -0,0 +1,403 @@ +// sogoms-db : Microservice d'accรจs ร  MariaDB. +// Chaque application cliente a sa propre base de donnรฉes. +package main + +import ( + "context" + "database/sql" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "os/signal" + "strings" + "sync" + "syscall" + + _ "github.com/go-sql-driver/mysql" + + "sogoms.com/internal/config" + "sogoms.com/internal/protocol" +) + +var ( + socketPath = flag.String("socket", "/run/sogoms-db.1.sock", "Unix socket path") + configDir = flag.String("config", "/config", "Configuration directory") +) + +// DBPool gรจre les connexions DB par application. +type DBPool struct { + registry *config.Registry + pools map[string]*sql.DB + mu sync.RWMutex +} + +func NewDBPool(registry *config.Registry) *DBPool { + return &DBPool{ + registry: registry, + pools: make(map[string]*sql.DB), + } +} + +// GetDB retourne une connexion DB pour l'application spรฉcifiรฉe. +func (p *DBPool) GetDB(appID string) (*sql.DB, error) { + p.mu.RLock() + db, ok := p.pools[appID] + p.mu.RUnlock() + if ok { + return db, nil + } + + // Crรฉer une nouvelle connexion + p.mu.Lock() + defer p.mu.Unlock() + + // Double-check aprรจs le lock + if db, ok := p.pools[appID]; ok { + return db, nil + } + + cfg, ok := p.registry.GetByApp(appID) + if !ok { + return nil, fmt.Errorf("unknown app: %s", appID) + } + + db, err := sql.Open("mysql", cfg.Database.DSN()) + if err != nil { + return nil, fmt.Errorf("open db: %w", err) + } + + // Configuration du pool + db.SetMaxOpenConns(10) + db.SetMaxIdleConns(5) + + // Test de connexion + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("ping db: %w", err) + } + + p.pools[appID] = db + log.Printf("[db] connected to database for app: %s", appID) + return db, nil +} + +// Close ferme toutes les connexions. +func (p *DBPool) Close() { + p.mu.Lock() + defer p.mu.Unlock() + for appID, db := range p.pools { + db.Close() + log.Printf("[db] closed connection for app: %s", appID) + } +} + +func main() { + flag.Parse() + log.SetFlags(log.Ltime | log.Lshortfile) + + // Charger les configurations + registry := config.NewRegistry(*configDir) + if err := registry.Load(); err != nil { + log.Fatalf("load config: %v", err) + } + log.Printf("[db] loaded apps: %v", registry.Apps()) + + // Pool de connexions DB + dbPool := NewDBPool(registry) + defer dbPool.Close() + + // Handler des requรชtes + handler := func(ctx context.Context, req *protocol.Request) *protocol.Response { + return handleRequest(ctx, req, dbPool) + } + + // Dรฉmarrer le serveur + server := protocol.NewServer(*socketPath, handler) + if err := server.Start(); err != nil { + log.Fatalf("start server: %v", err) + } + + log.Printf("[db] sogoms-db started on %s", *socketPath) + + // Attendre signal d'arrรชt + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + + log.Printf("[db] shutting down...") + server.Stop() +} + +func handleRequest(ctx context.Context, req *protocol.Request, dbPool *DBPool) *protocol.Response { + // L'app_id doit รชtre fourni + appID, ok := req.Params["app_id"].(string) + if !ok || appID == "" { + return protocol.Failure(req.ID, "MISSING_APP_ID", "app_id is required") + } + + db, err := dbPool.GetDB(appID) + if err != nil { + return protocol.Failure(req.ID, "DB_ERROR", err.Error()) + } + + switch req.Action { + case "query": + return handleQuery(req, db) + case "query_one": + return handleQueryOne(req, db) + case "insert": + return handleInsert(req, db) + case "update": + return handleUpdate(req, db) + case "delete": + return handleDelete(req, db) + case "health": + return handleHealth(req, db) + default: + return protocol.Failure(req.ID, "UNKNOWN_ACTION", "unknown action: "+req.Action) + } +} + +// handleQuery exรฉcute un SELECT et retourne plusieurs lignes. +func handleQuery(req *protocol.Request, db *sql.DB) *protocol.Response { + query, args, err := extractQueryParams(req.Params) + if err != nil { + return protocol.Failure(req.ID, "INVALID_PARAMS", err.Error()) + } + + rows, err := db.Query(query, args...) + if err != nil { + return protocol.Failure(req.ID, "QUERY_ERROR", err.Error()) + } + defer rows.Close() + + results, err := scanRows(rows) + if err != nil { + return protocol.Failure(req.ID, "SCAN_ERROR", err.Error()) + } + + return protocol.Success(req.ID, results) +} + +// handleQueryOne exรฉcute un SELECT et retourne une seule ligne. +func handleQueryOne(req *protocol.Request, db *sql.DB) *protocol.Response { + query, args, err := extractQueryParams(req.Params) + if err != nil { + return protocol.Failure(req.ID, "INVALID_PARAMS", err.Error()) + } + + rows, err := db.Query(query, args...) + if err != nil { + return protocol.Failure(req.ID, "QUERY_ERROR", err.Error()) + } + defer rows.Close() + + results, err := scanRows(rows) + if err != nil { + return protocol.Failure(req.ID, "SCAN_ERROR", err.Error()) + } + + if len(results) == 0 { + return protocol.Failure(req.ID, "NOT_FOUND", "no rows found") + } + + return protocol.Success(req.ID, results[0]) +} + +// handleInsert exรฉcute un INSERT et retourne l'ID insรฉrรฉ. +func handleInsert(req *protocol.Request, db *sql.DB) *protocol.Response { + table, ok := req.Params["table"].(string) + if !ok { + return protocol.Failure(req.ID, "INVALID_PARAMS", "table is required") + } + + data, ok := req.Params["data"].(map[string]any) + if !ok { + return protocol.Failure(req.ID, "INVALID_PARAMS", "data is required") + } + + // Construire la requรชte INSERT + columns := make([]string, 0, len(data)) + placeholders := make([]string, 0, len(data)) + values := make([]any, 0, len(data)) + + for col, val := range data { + columns = append(columns, col) + placeholders = append(placeholders, "?") + values = append(values, val) + } + + query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", + table, + strings.Join(columns, ", "), + strings.Join(placeholders, ", ")) + + result, err := db.Exec(query, values...) + if err != nil { + return protocol.Failure(req.ID, "INSERT_ERROR", err.Error()) + } + + insertID, _ := result.LastInsertId() + return protocol.Success(req.ID, map[string]any{ + "insert_id": insertID, + }) +} + +// handleUpdate exรฉcute un UPDATE et retourne le nombre de lignes affectรฉes. +func handleUpdate(req *protocol.Request, db *sql.DB) *protocol.Response { + table, ok := req.Params["table"].(string) + if !ok { + return protocol.Failure(req.ID, "INVALID_PARAMS", "table is required") + } + + data, ok := req.Params["data"].(map[string]any) + if !ok { + return protocol.Failure(req.ID, "INVALID_PARAMS", "data is required") + } + + where, ok := req.Params["where"].(map[string]any) + if !ok { + return protocol.Failure(req.ID, "INVALID_PARAMS", "where is required") + } + + // Construire SET + setClauses := make([]string, 0, len(data)) + values := make([]any, 0, len(data)+len(where)) + + for col, val := range data { + setClauses = append(setClauses, col+" = ?") + values = append(values, val) + } + + // Construire WHERE + whereClauses := make([]string, 0, len(where)) + for col, val := range where { + whereClauses = append(whereClauses, col+" = ?") + values = append(values, val) + } + + query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", + table, + strings.Join(setClauses, ", "), + strings.Join(whereClauses, " AND ")) + + result, err := db.Exec(query, values...) + if err != nil { + return protocol.Failure(req.ID, "UPDATE_ERROR", err.Error()) + } + + affected, _ := result.RowsAffected() + return protocol.Success(req.ID, map[string]any{ + "affected_rows": affected, + }) +} + +// handleDelete exรฉcute un DELETE et retourne le nombre de lignes affectรฉes. +func handleDelete(req *protocol.Request, db *sql.DB) *protocol.Response { + table, ok := req.Params["table"].(string) + if !ok { + return protocol.Failure(req.ID, "INVALID_PARAMS", "table is required") + } + + where, ok := req.Params["where"].(map[string]any) + if !ok { + return protocol.Failure(req.ID, "INVALID_PARAMS", "where is required") + } + + // Construire WHERE + whereClauses := make([]string, 0, len(where)) + values := make([]any, 0, len(where)) + + for col, val := range where { + whereClauses = append(whereClauses, col+" = ?") + values = append(values, val) + } + + query := fmt.Sprintf("DELETE FROM %s WHERE %s", + table, + strings.Join(whereClauses, " AND ")) + + result, err := db.Exec(query, values...) + if err != nil { + return protocol.Failure(req.ID, "DELETE_ERROR", err.Error()) + } + + affected, _ := result.RowsAffected() + return protocol.Success(req.ID, map[string]any{ + "affected_rows": affected, + }) +} + +// handleHealth vรฉrifie la connexion ร  la DB. +func handleHealth(req *protocol.Request, db *sql.DB) *protocol.Response { + if err := db.Ping(); err != nil { + return protocol.Failure(req.ID, "UNHEALTHY", err.Error()) + } + return protocol.Success(req.ID, map[string]any{"status": "ok"}) +} + +// extractQueryParams extrait query et args des paramรจtres. +func extractQueryParams(params map[string]any) (string, []any, error) { + query, ok := params["query"].(string) + if !ok { + return "", nil, fmt.Errorf("query is required") + } + + var args []any + if argsRaw, ok := params["args"]; ok { + switch v := argsRaw.(type) { + case []any: + args = v + default: + // Essayer de convertir via JSON + data, _ := json.Marshal(argsRaw) + json.Unmarshal(data, &args) + } + } + + return query, args, nil +} + +// scanRows convertit les rรฉsultats SQL en slice de maps. +func scanRows(rows *sql.Rows) ([]map[string]any, error) { + columns, err := rows.Columns() + if err != nil { + return nil, err + } + + var results []map[string]any + + for rows.Next() { + // Crรฉer des pointeurs pour scanner + values := make([]any, len(columns)) + valuePtrs := make([]any, len(columns)) + for i := range values { + valuePtrs[i] = &values[i] + } + + if err := rows.Scan(valuePtrs...); err != nil { + return nil, err + } + + // Construire la map + row := make(map[string]any) + for i, col := range columns { + val := values[i] + // Convertir []byte en string + if b, ok := val.([]byte); ok { + row[col] = string(b) + } else { + row[col] = val + } + } + results = append(results, row) + } + + if results == nil { + results = []map[string]any{} + } + + return results, rows.Err() +} diff --git a/cmd/sogoms/email/main.go b/cmd/sogoms/email/main.go new file mode 100755 index 0000000..da29a2c --- /dev/null +++ b/cmd/sogoms/email/main.go @@ -0,0 +1,4 @@ +package main + +func main() { +} diff --git a/cmd/sogoms/pdf/main.go b/cmd/sogoms/pdf/main.go new file mode 100755 index 0000000..da29a2c --- /dev/null +++ b/cmd/sogoms/pdf/main.go @@ -0,0 +1,4 @@ +package main + +func main() { +} diff --git a/cmd/sogoms/storage/main.go b/cmd/sogoms/storage/main.go new file mode 100755 index 0000000..da29a2c --- /dev/null +++ b/cmd/sogoms/storage/main.go @@ -0,0 +1,4 @@ +package main + +func main() { +} diff --git a/cmd/sogorch/main.go b/cmd/sogorch/main.go new file mode 100755 index 0000000..da29a2c --- /dev/null +++ b/cmd/sogorch/main.go @@ -0,0 +1,4 @@ +package main + +func main() { +} diff --git a/config/routes/prokov.yaml b/config/routes/prokov.yaml new file mode 100644 index 0000000..b398350 --- /dev/null +++ b/config/routes/prokov.yaml @@ -0,0 +1,129 @@ +# Routes API Prokov +# Gestion de projets et tรขches + +app: prokov +version: "1.0" +base_path: /api + +# Identification par hostname +hosts: + - prokov.unikoffice.com + - prokov.sogoms.com + +# Base de donnรฉes +database: + host: 13.23.33.4 + port: 3306 + user: prokov_user + password_file: /secrets/prokov_db_pass + name: prokov + +# Authentification +auth: + jwt_secret_file: /secrets/prokov_jwt_secret + jwt_expiry: 24h + +# Routes +routes: + # === AUTH === + - path: /auth/register + method: POST + scenario: prokov/auth/register + auth: false + + - path: /auth/login + method: POST + scenario: prokov/auth/login + auth: false + + - path: /auth/logout + method: POST + scenario: prokov/auth/logout + + - path: /auth/me + method: GET + scenario: prokov/auth/me + + # === PROJECTS === + - path: /projects + method: GET + scenario: prokov/projects/list + + - path: /projects + method: POST + scenario: prokov/projects/create + + - path: /projects/{id} + method: GET + scenario: prokov/projects/show + + - path: /projects/{id} + method: PUT + scenario: prokov/projects/update + + - path: /projects/{id} + method: DELETE + scenario: prokov/projects/delete + + # === TASKS === + - path: /tasks + method: GET + scenario: prokov/tasks/list + + - path: /tasks + method: POST + scenario: prokov/tasks/create + + - path: /tasks/{id} + method: GET + scenario: prokov/tasks/show + + - path: /tasks/{id} + method: PUT + scenario: prokov/tasks/update + + - path: /tasks/{id} + method: DELETE + scenario: prokov/tasks/delete + + # === TAGS === + - path: /tags + method: GET + scenario: prokov/tags/list + + - path: /tags + method: POST + scenario: prokov/tags/create + + - path: /tags/{id} + method: GET + scenario: prokov/tags/show + + - path: /tags/{id} + method: PUT + scenario: prokov/tags/update + + - path: /tags/{id} + method: DELETE + scenario: prokov/tags/delete + + # === STATUSES === + - path: /statuses + method: GET + scenario: prokov/statuses/list + + - path: /statuses + method: POST + scenario: prokov/statuses/create + + - path: /statuses/{id} + method: GET + scenario: prokov/statuses/show + + - path: /statuses/{id} + method: PUT + scenario: prokov/statuses/update + + - path: /statuses/{id} + method: DELETE + scenario: prokov/statuses/delete diff --git a/config/scenarios/prokov/auth/login.yaml b/config/scenarios/prokov/auth/login.yaml new file mode 100644 index 0000000..ea0bca9 --- /dev/null +++ b/config/scenarios/prokov/auth/login.yaml @@ -0,0 +1,58 @@ +# Scรฉnario: Connexion utilisateur +name: login +version: "1.0" +description: Authentifie un utilisateur et retourne un JWT + +input: + required: + - email + - password + validation: + email: + type: string + format: email + password: + type: string + min_length: 1 + +steps: + - id: get_user + service: db + action: query_one + params: + query: "SELECT id, email, name, password FROM users WHERE email = ?" + args: ["{{input.email}}"] + on_error: abort + error_message: "Email ou mot de passe incorrect" + error_status: 401 + + - id: verify_password + service: auth + action: verify_password + params: + hash: "{{steps.get_user.result.password}}" + password: "{{input.password}}" + on_error: abort + error_message: "Email ou mot de passe incorrect" + error_status: 401 + + - id: generate_token + service: auth + action: generate_jwt + params: + claims: + sub: "{{steps.get_user.result.id}}" + email: "{{steps.get_user.result.email}}" + name: "{{steps.get_user.result.name}}" + +output: + status: 200 + body: + success: true + message: "Connexion rรฉussie" + data: + token: "{{steps.generate_token.result.token}}" + user: + id: "{{steps.get_user.result.id}}" + email: "{{steps.get_user.result.email}}" + name: "{{steps.get_user.result.name}}" diff --git a/config/scenarios/prokov/auth/logout.yaml b/config/scenarios/prokov/auth/logout.yaml new file mode 100644 index 0000000..ee78c28 --- /dev/null +++ b/config/scenarios/prokov/auth/logout.yaml @@ -0,0 +1,13 @@ +# Scรฉnario: Dรฉconnexion +name: logout +version: "1.0" +description: Dรฉconnecte l'utilisateur (cรดtรฉ client, invalide le JWT) + +# Avec JWT stateless, le logout est gรฉrรฉ cรดtรฉ client +# Ce endpoint existe pour la compatibilitรฉ API + +output: + status: 200 + body: + success: true + message: "Dรฉconnexion rรฉussie" diff --git a/config/scenarios/prokov/auth/me.yaml b/config/scenarios/prokov/auth/me.yaml new file mode 100644 index 0000000..4507cc7 --- /dev/null +++ b/config/scenarios/prokov/auth/me.yaml @@ -0,0 +1,22 @@ +# Scรฉnario: Rรฉcupรฉrer l'utilisateur connectรฉ +name: me +version: "1.0" +description: Retourne les informations de l'utilisateur authentifiรฉ + +steps: + - id: get_user + service: db + action: query_one + params: + query: "SELECT id, email, name, created_at FROM users WHERE id = ?" + args: ["{{auth.user_id}}"] + on_error: abort + error_message: "Utilisateur non trouvรฉ" + error_status: 404 + +output: + status: 200 + body: + success: true + data: + user: "{{steps.get_user.result}}" diff --git a/config/scenarios/prokov/auth/register.yaml b/config/scenarios/prokov/auth/register.yaml new file mode 100644 index 0000000..94eed14 --- /dev/null +++ b/config/scenarios/prokov/auth/register.yaml @@ -0,0 +1,93 @@ +# Scรฉnario: Inscription utilisateur +name: register +version: "1.0" +description: Crรฉe un nouvel utilisateur + +input: + required: + - email + - password + - name + validation: + email: + type: string + format: email + max_length: 255 + password: + type: string + min_length: 6 + max_length: 255 + name: + type: string + min_length: 2 + max_length: 100 + +steps: + - id: check_email + service: db + action: query_one + params: + query: "SELECT id FROM users WHERE email = ?" + args: ["{{input.email}}"] + on_success: abort + error_message: "Cet email est dรฉjร  utilisรฉ" + error_status: 409 + + - id: hash_password + service: auth + action: hash_password + params: + password: "{{input.password}}" + + - id: create_user + service: db + action: insert + params: + table: users + data: + email: "{{input.email}}" + password: "{{steps.hash_password.result.hash}}" + name: "{{input.name}}" + + - id: create_default_statuses + service: db + action: exec + params: + query: | + INSERT INTO statuses (user_id, project_id, code, name, color, position) VALUES + (?, NULL, 10, 'Backlog', '#6B7280', 10), + (?, NULL, 20, 'ร€ faire', '#3B82F6', 20), + (?, NULL, 30, 'En cours', '#F59E0B', 30), + (?, NULL, 40, 'ร€ tester', '#8B5CF6', 40), + (?, NULL, 50, 'Livrรฉ', '#10B981', 50), + (?, NULL, 60, 'Terminรฉ', '#059669', 60), + (?, NULL, 70, 'Archivรฉ', '#9CA3AF', 70) + args: + - "{{steps.create_user.insert_id}}" + - "{{steps.create_user.insert_id}}" + - "{{steps.create_user.insert_id}}" + - "{{steps.create_user.insert_id}}" + - "{{steps.create_user.insert_id}}" + - "{{steps.create_user.insert_id}}" + - "{{steps.create_user.insert_id}}" + + - id: generate_token + service: auth + action: generate_jwt + params: + claims: + sub: "{{steps.create_user.insert_id}}" + email: "{{input.email}}" + name: "{{input.name}}" + +output: + status: 201 + body: + success: true + message: "Inscription rรฉussie" + data: + token: "{{steps.generate_token.result.token}}" + user: + id: "{{steps.create_user.insert_id}}" + email: "{{input.email}}" + name: "{{input.name}}" diff --git a/config/scenarios/prokov/projects/create.yaml b/config/scenarios/prokov/projects/create.yaml new file mode 100644 index 0000000..048c4da --- /dev/null +++ b/config/scenarios/prokov/projects/create.yaml @@ -0,0 +1,95 @@ +# Scรฉnario: Crรฉer un projet +name: projects_create +version: "1.0" +description: Crรฉe un nouveau projet + +input: + required: + - name + optional: + - description + - parent_id + - position + - tags + defaults: + position: 0 + validation: + name: + type: string + min_length: 1 + max_length: 100 + description: + type: string + max_length: 65535 + parent_id: + type: int + position: + type: int + +steps: + - id: check_parent + service: db + action: query_one + condition: "{{input.parent_id != null}}" + params: + query: "SELECT id FROM projects WHERE id = ? AND user_id = ?" + args: ["{{input.parent_id}}", "{{auth.user_id}}"] + on_error: abort + error_message: "Projet parent non trouvรฉ" + error_status: 422 + + - id: insert_project + service: db + action: insert + params: + table: projects + data: + user_id: "{{auth.user_id}}" + parent_id: "{{input.parent_id}}" + name: "{{input.name}}" + description: "{{input.description}}" + position: "{{input.position}}" + + - id: sync_tags + service: db + action: exec + condition: "{{input.tags != null && input.tags | length > 0}}" + foreach: "{{input.tags}}" + foreach_as: tag_id + params: + query: | + INSERT INTO project_tags (project_id, tag_id) + SELECT ?, id FROM tags WHERE id = ? AND user_id = ? + args: ["{{steps.insert_project.insert_id}}", "{{tag_id}}", "{{auth.user_id}}"] + + - id: get_project + service: db + action: query_one + params: + query: "SELECT * FROM projects WHERE id = ?" + args: ["{{steps.insert_project.insert_id}}"] + + - id: get_tags + service: db + action: query + params: + query: | + SELECT t.id, t.name, t.color + FROM tags t + JOIN project_tags pt ON t.id = pt.tag_id + WHERE pt.project_id = ? + args: ["{{steps.insert_project.insert_id}}"] + +output: + status: 201 + body: + success: true + message: "Projet crรฉรฉ" + data: + id: "{{steps.get_project.result.id}}" + name: "{{steps.get_project.result.name}}" + description: "{{steps.get_project.result.description}}" + parent_id: "{{steps.get_project.result.parent_id}}" + position: "{{steps.get_project.result.position}}" + created_at: "{{steps.get_project.result.created_at}}" + tags: "{{steps.get_tags.result}}" diff --git a/config/scenarios/prokov/projects/delete.yaml b/config/scenarios/prokov/projects/delete.yaml new file mode 100644 index 0000000..36324ba --- /dev/null +++ b/config/scenarios/prokov/projects/delete.yaml @@ -0,0 +1,36 @@ +# Scรฉnario: Supprimer un projet +name: projects_delete +version: "1.0" +description: Supprime un projet (cascade sur sous-projets et tรขches) + +input: + required: + - id + validation: + id: + type: int + +steps: + - id: check_project + service: db + action: query_one + params: + query: "SELECT id FROM projects WHERE id = ? AND user_id = ?" + args: ["{{input.id}}", "{{auth.user_id}}"] + on_error: abort + error_message: "Projet non trouvรฉ" + error_status: 404 + + - id: delete_project + service: db + action: delete + params: + table: projects + where: + id: "{{input.id}}" + +output: + status: 200 + body: + success: true + message: "Projet supprimรฉ" diff --git a/config/scenarios/prokov/projects/list.yaml b/config/scenarios/prokov/projects/list.yaml new file mode 100644 index 0000000..956469b --- /dev/null +++ b/config/scenarios/prokov/projects/list.yaml @@ -0,0 +1,27 @@ +# Scรฉnario: Liste des projets (arborescence) +name: projects_list +version: "1.0" +description: Retourne tous les projets de l'utilisateur en arborescence + +steps: + - id: get_projects + service: db + action: query + params: + query: | + SELECT p.*, + GROUP_CONCAT(t.id) as tag_ids, + GROUP_CONCAT(t.name) as tag_names + FROM projects p + LEFT JOIN project_tags pt ON p.id = pt.project_id + LEFT JOIN tags t ON pt.tag_id = t.id + WHERE p.user_id = ? + GROUP BY p.id + ORDER BY p.parent_id ASC, p.position ASC, p.name ASC + args: ["{{auth.user_id}}"] + +output: + status: 200 + body: + success: true + data: "{{steps.get_projects.result | tree}}" diff --git a/config/scenarios/prokov/projects/show.yaml b/config/scenarios/prokov/projects/show.yaml new file mode 100644 index 0000000..c7b1c1a --- /dev/null +++ b/config/scenarios/prokov/projects/show.yaml @@ -0,0 +1,57 @@ +# Scรฉnario: Dรฉtail d'un projet +name: projects_show +version: "1.0" +description: Retourne un projet avec ses tags et sous-projets + +input: + required: + - id + validation: + id: + type: int + +steps: + - id: get_project + service: db + action: query_one + params: + query: "SELECT * FROM projects WHERE id = ? AND user_id = ?" + args: ["{{input.id}}", "{{auth.user_id}}"] + on_error: abort + error_message: "Projet non trouvรฉ" + error_status: 404 + + - id: get_tags + service: db + action: query + params: + query: | + SELECT t.id, t.name, t.color + FROM tags t + JOIN project_tags pt ON t.id = pt.tag_id + WHERE pt.project_id = ? + args: ["{{input.id}}"] + + - id: get_children + service: db + action: query + params: + query: | + SELECT * FROM projects + WHERE parent_id = ? AND user_id = ? + ORDER BY position ASC, name ASC + args: ["{{input.id}}", "{{auth.user_id}}"] + +output: + status: 200 + body: + success: true + data: + id: "{{steps.get_project.result.id}}" + name: "{{steps.get_project.result.name}}" + description: "{{steps.get_project.result.description}}" + parent_id: "{{steps.get_project.result.parent_id}}" + position: "{{steps.get_project.result.position}}" + created_at: "{{steps.get_project.result.created_at}}" + tags: "{{steps.get_tags.result}}" + children: "{{steps.get_children.result}}" diff --git a/config/scenarios/prokov/projects/update.yaml b/config/scenarios/prokov/projects/update.yaml new file mode 100644 index 0000000..81d8cd7 --- /dev/null +++ b/config/scenarios/prokov/projects/update.yaml @@ -0,0 +1,115 @@ +# Scรฉnario: Modifier un projet +name: projects_update +version: "1.0" +description: Met ร  jour un projet existant + +input: + required: + - id + optional: + - name + - description + - parent_id + - position + - tags + validation: + id: + type: int + name: + type: string + min_length: 1 + max_length: 100 + description: + type: string + max_length: 65535 + parent_id: + type: int + position: + type: int + +steps: + - id: get_project + service: db + action: query_one + params: + query: "SELECT * FROM projects WHERE id = ? AND user_id = ?" + args: ["{{input.id}}", "{{auth.user_id}}"] + on_error: abort + error_message: "Projet non trouvรฉ" + error_status: 404 + + - id: check_parent + service: db + action: query_one + condition: "{{input.parent_id != null && input.parent_id != input.id}}" + params: + query: "SELECT id FROM projects WHERE id = ? AND user_id = ?" + args: ["{{input.parent_id}}", "{{auth.user_id}}"] + on_error: abort + error_message: "Projet parent invalide" + error_status: 422 + + - id: update_project + service: db + action: update + params: + table: projects + where: + id: "{{input.id}}" + data: + name: "{{input.name ?? steps.get_project.result.name}}" + description: "{{input.description ?? steps.get_project.result.description}}" + parent_id: "{{input.parent_id ?? steps.get_project.result.parent_id}}" + position: "{{input.position ?? steps.get_project.result.position}}" + + - id: clear_tags + service: db + action: delete + condition: "{{input.tags != null}}" + params: + table: project_tags + where: + project_id: "{{input.id}}" + + - id: sync_tags + service: db + action: exec + condition: "{{input.tags != null && input.tags | length > 0}}" + foreach: "{{input.tags}}" + foreach_as: tag_id + params: + query: | + INSERT INTO project_tags (project_id, tag_id) + SELECT ?, id FROM tags WHERE id = ? AND user_id = ? + args: ["{{input.id}}", "{{tag_id}}", "{{auth.user_id}}"] + + - id: get_updated + service: db + action: query_one + params: + query: "SELECT * FROM projects WHERE id = ?" + args: ["{{input.id}}"] + + - id: get_tags + service: db + action: query + params: + query: | + SELECT t.id, t.name, t.color + FROM tags t + JOIN project_tags pt ON t.id = pt.tag_id + WHERE pt.project_id = ? + args: ["{{input.id}}"] + +output: + status: 200 + body: + success: true + message: "Projet mis ร  jour" + data: + id: "{{steps.get_updated.result.id}}" + name: "{{steps.get_updated.result.name}}" + description: "{{steps.get_updated.result.description}}" + parent_id: "{{steps.get_updated.result.parent_id}}" + position: "{{steps.get_updated.result.position}}" + tags: "{{steps.get_tags.result}}" diff --git a/config/scenarios/prokov/statuses/create.yaml b/config/scenarios/prokov/statuses/create.yaml new file mode 100644 index 0000000..4a66003 --- /dev/null +++ b/config/scenarios/prokov/statuses/create.yaml @@ -0,0 +1,68 @@ +# Scรฉnario: Crรฉer un statut +name: statuses_create +version: "1.0" +description: Crรฉe un nouveau statut + +input: + required: + - code + - name + optional: + - color + - project_id + - position + defaults: + color: "#6B7280" + validation: + code: + type: int + name: + type: string + min_length: 1 + max_length: 50 + color: + type: string + max_length: 7 + project_id: + type: int + position: + type: int + +steps: + - id: check_project + service: db + action: query_one + condition: "{{input.project_id != null}}" + params: + query: "SELECT id FROM projects WHERE id = ? AND user_id = ?" + args: ["{{input.project_id}}", "{{auth.user_id}}"] + on_error: abort + error_message: "Projet invalide" + error_status: 422 + + - id: insert_status + service: db + action: insert + params: + table: statuses + data: + user_id: "{{auth.user_id}}" + project_id: "{{input.project_id}}" + code: "{{input.code}}" + name: "{{input.name}}" + color: "{{input.color}}" + position: "{{input.position ?? input.code}}" + + - id: get_status + service: db + action: query_one + params: + query: "SELECT * FROM statuses WHERE id = ?" + args: ["{{steps.insert_status.insert_id}}"] + +output: + status: 201 + body: + success: true + message: "Statut crรฉรฉ" + data: "{{steps.get_status.result}}" diff --git a/config/scenarios/prokov/statuses/delete.yaml b/config/scenarios/prokov/statuses/delete.yaml new file mode 100644 index 0000000..10a3f61 --- /dev/null +++ b/config/scenarios/prokov/statuses/delete.yaml @@ -0,0 +1,51 @@ +# Scรฉnario: Supprimer un statut +name: statuses_delete +version: "1.0" +description: Supprime un statut (si aucune tรขche ne l'utilise) + +input: + required: + - id + validation: + id: + type: int + +steps: + - id: check_status + service: db + action: query_one + params: + query: "SELECT id FROM statuses WHERE id = ? AND user_id = ?" + args: ["{{input.id}}", "{{auth.user_id}}"] + on_error: abort + error_message: "Statut non trouvรฉ" + error_status: 404 + + - id: count_tasks + service: db + action: query_one + params: + query: "SELECT COUNT(*) as count FROM tasks WHERE status_id = ?" + args: ["{{input.id}}"] + + - id: check_usage + service: system + action: assert + params: + condition: "{{steps.count_tasks.result.count == 0}}" + error_message: "Impossible de supprimer : {{steps.count_tasks.result.count}} tรขche(s) utilisent ce statut" + error_status: 409 + + - id: delete_status + service: db + action: delete + params: + table: statuses + where: + id: "{{input.id}}" + +output: + status: 200 + body: + success: true + message: "Statut supprimรฉ" diff --git a/config/scenarios/prokov/statuses/list.yaml b/config/scenarios/prokov/statuses/list.yaml new file mode 100644 index 0000000..90d6724 --- /dev/null +++ b/config/scenarios/prokov/statuses/list.yaml @@ -0,0 +1,44 @@ +# Scรฉnario: Liste des statuts +name: statuses_list +version: "1.0" +description: Retourne les statuts avec filtres optionnels + +input: + optional: + - project_id + - global + validation: + project_id: + type: int + global: + type: bool + +steps: + - id: get_statuses + service: db + action: query + params: + query: | + SELECT s.*, + (SELECT COUNT(*) FROM tasks t WHERE t.status_id = s.id) as task_count + FROM statuses s + WHERE s.user_id = ? + AND ( + (? IS NOT NULL AND (s.project_id = ? OR s.project_id IS NULL)) + OR (? IS NOT NULL AND s.project_id IS NULL) + OR (? IS NULL AND ? IS NULL) + ) + ORDER BY s.position ASC, s.code ASC + args: + - "{{auth.user_id}}" + - "{{input.project_id}}" + - "{{input.project_id}}" + - "{{input.global}}" + - "{{input.project_id}}" + - "{{input.global}}" + +output: + status: 200 + body: + success: true + data: "{{steps.get_statuses.result}}" diff --git a/config/scenarios/prokov/statuses/show.yaml b/config/scenarios/prokov/statuses/show.yaml new file mode 100644 index 0000000..1180a46 --- /dev/null +++ b/config/scenarios/prokov/statuses/show.yaml @@ -0,0 +1,42 @@ +# Scรฉnario: Dรฉtail d'un statut +name: statuses_show +version: "1.0" +description: Retourne un statut avec le nombre de tรขches + +input: + required: + - id + validation: + id: + type: int + +steps: + - id: get_status + service: db + action: query_one + params: + query: "SELECT * FROM statuses WHERE id = ? AND user_id = ?" + args: ["{{input.id}}", "{{auth.user_id}}"] + on_error: abort + error_message: "Statut non trouvรฉ" + error_status: 404 + + - id: count_tasks + service: db + action: query_one + params: + query: "SELECT COUNT(*) as count FROM tasks WHERE status_id = ?" + args: ["{{input.id}}"] + +output: + status: 200 + body: + success: true + data: + id: "{{steps.get_status.result.id}}" + code: "{{steps.get_status.result.code}}" + name: "{{steps.get_status.result.name}}" + color: "{{steps.get_status.result.color}}" + project_id: "{{steps.get_status.result.project_id}}" + position: "{{steps.get_status.result.position}}" + task_count: "{{steps.count_tasks.result.count}}" diff --git a/config/scenarios/prokov/statuses/update.yaml b/config/scenarios/prokov/statuses/update.yaml new file mode 100644 index 0000000..1963a31 --- /dev/null +++ b/config/scenarios/prokov/statuses/update.yaml @@ -0,0 +1,65 @@ +# Scรฉnario: Modifier un statut +name: statuses_update +version: "1.0" +description: Met ร  jour un statut existant + +input: + required: + - id + optional: + - code + - name + - color + - position + validation: + id: + type: int + code: + type: int + name: + type: string + min_length: 1 + max_length: 50 + color: + type: string + max_length: 7 + position: + type: int + +steps: + - id: get_status + service: db + action: query_one + params: + query: "SELECT * FROM statuses WHERE id = ? AND user_id = ?" + args: ["{{input.id}}", "{{auth.user_id}}"] + on_error: abort + error_message: "Statut non trouvรฉ" + error_status: 404 + + - id: update_status + service: db + action: update + params: + table: statuses + where: + id: "{{input.id}}" + data: + code: "{{input.code ?? steps.get_status.result.code}}" + name: "{{input.name ?? steps.get_status.result.name}}" + color: "{{input.color ?? steps.get_status.result.color}}" + position: "{{input.position ?? steps.get_status.result.position}}" + + - id: get_updated + service: db + action: query_one + params: + query: "SELECT * FROM statuses WHERE id = ?" + args: ["{{input.id}}"] + +output: + status: 200 + body: + success: true + message: "Statut mis ร  jour" + data: "{{steps.get_updated.result}}" diff --git a/config/scenarios/prokov/tags/create.yaml b/config/scenarios/prokov/tags/create.yaml new file mode 100644 index 0000000..356aff5 --- /dev/null +++ b/config/scenarios/prokov/tags/create.yaml @@ -0,0 +1,55 @@ +# Scรฉnario: Crรฉer un tag +name: tags_create +version: "1.0" +description: Crรฉe un nouveau tag + +input: + required: + - name + optional: + - color + defaults: + color: "#3B82F6" + validation: + name: + type: string + min_length: 1 + max_length: 50 + color: + type: string + max_length: 7 + +steps: + - id: check_unique + service: db + action: query_one + params: + query: "SELECT id FROM tags WHERE user_id = ? AND name = ?" + args: ["{{auth.user_id}}", "{{input.name}}"] + on_success: abort + error_message: "Ce tag existe dรฉjร " + error_status: 409 + + - id: insert_tag + service: db + action: insert + params: + table: tags + data: + user_id: "{{auth.user_id}}" + name: "{{input.name}}" + color: "{{input.color}}" + + - id: get_tag + service: db + action: query_one + params: + query: "SELECT * FROM tags WHERE id = ?" + args: ["{{steps.insert_tag.insert_id}}"] + +output: + status: 201 + body: + success: true + message: "Tag crรฉรฉ" + data: "{{steps.get_tag.result}}" diff --git a/config/scenarios/prokov/tags/delete.yaml b/config/scenarios/prokov/tags/delete.yaml new file mode 100644 index 0000000..a83e13c --- /dev/null +++ b/config/scenarios/prokov/tags/delete.yaml @@ -0,0 +1,36 @@ +# Scรฉnario: Supprimer un tag +name: tags_delete +version: "1.0" +description: Supprime un tag (les associations sont supprimรฉes en cascade) + +input: + required: + - id + validation: + id: + type: int + +steps: + - id: check_tag + service: db + action: query_one + params: + query: "SELECT id FROM tags WHERE id = ? AND user_id = ?" + args: ["{{input.id}}", "{{auth.user_id}}"] + on_error: abort + error_message: "Tag non trouvรฉ" + error_status: 404 + + - id: delete_tag + service: db + action: delete + params: + table: tags + where: + id: "{{input.id}}" + +output: + status: 200 + body: + success: true + message: "Tag supprimรฉ" diff --git a/config/scenarios/prokov/tags/list.yaml b/config/scenarios/prokov/tags/list.yaml new file mode 100644 index 0000000..30b703b --- /dev/null +++ b/config/scenarios/prokov/tags/list.yaml @@ -0,0 +1,24 @@ +# Scรฉnario: Liste des tags +name: tags_list +version: "1.0" +description: Retourne tous les tags de l'utilisateur avec compteurs + +steps: + - id: get_tags + service: db + action: query + params: + query: | + SELECT t.*, + (SELECT COUNT(*) FROM project_tags pt WHERE pt.tag_id = t.id) as project_count, + (SELECT COUNT(*) FROM task_tags tt WHERE tt.tag_id = t.id) as task_count + FROM tags t + WHERE t.user_id = ? + ORDER BY t.name ASC + args: ["{{auth.user_id}}"] + +output: + status: 200 + body: + success: true + data: "{{steps.get_tags.result}}" diff --git a/config/scenarios/prokov/tags/show.yaml b/config/scenarios/prokov/tags/show.yaml new file mode 100644 index 0000000..5c6a843 --- /dev/null +++ b/config/scenarios/prokov/tags/show.yaml @@ -0,0 +1,58 @@ +# Scรฉnario: Dรฉtail d'un tag +name: tags_show +version: "1.0" +description: Retourne un tag avec ses projets et tรขches associรฉs + +input: + required: + - id + validation: + id: + type: int + +steps: + - id: get_tag + service: db + action: query_one + params: + query: "SELECT * FROM tags WHERE id = ? AND user_id = ?" + args: ["{{input.id}}", "{{auth.user_id}}"] + on_error: abort + error_message: "Tag non trouvรฉ" + error_status: 404 + + - id: get_projects + service: db + action: query + params: + query: | + SELECT p.id, p.name + FROM projects p + JOIN project_tags pt ON p.id = pt.project_id + WHERE pt.tag_id = ? + ORDER BY p.name ASC + args: ["{{input.id}}"] + + - id: get_tasks + service: db + action: query + params: + query: | + SELECT t.id, t.title, t.status_id, s.name as status_name + FROM tasks t + JOIN task_tags tt ON t.id = tt.task_id + LEFT JOIN statuses s ON t.status_id = s.id + WHERE tt.tag_id = ? + ORDER BY t.created_at DESC + args: ["{{input.id}}"] + +output: + status: 200 + body: + success: true + data: + id: "{{steps.get_tag.result.id}}" + name: "{{steps.get_tag.result.name}}" + color: "{{steps.get_tag.result.color}}" + projects: "{{steps.get_projects.result}}" + tasks: "{{steps.get_tasks.result}}" diff --git a/config/scenarios/prokov/tags/update.yaml b/config/scenarios/prokov/tags/update.yaml new file mode 100644 index 0000000..ed52776 --- /dev/null +++ b/config/scenarios/prokov/tags/update.yaml @@ -0,0 +1,68 @@ +# Scรฉnario: Modifier un tag +name: tags_update +version: "1.0" +description: Met ร  jour un tag existant + +input: + required: + - id + optional: + - name + - color + validation: + id: + type: int + name: + type: string + min_length: 1 + max_length: 50 + color: + type: string + max_length: 7 + +steps: + - id: get_tag + service: db + action: query_one + params: + query: "SELECT * FROM tags WHERE id = ? AND user_id = ?" + args: ["{{input.id}}", "{{auth.user_id}}"] + on_error: abort + error_message: "Tag non trouvรฉ" + error_status: 404 + + - id: check_unique + service: db + action: query_one + condition: "{{input.name != null}}" + params: + query: "SELECT id FROM tags WHERE user_id = ? AND name = ? AND id != ?" + args: ["{{auth.user_id}}", "{{input.name}}", "{{input.id}}"] + on_success: abort + error_message: "Ce tag existe dรฉjร " + error_status: 409 + + - id: update_tag + service: db + action: update + params: + table: tags + where: + id: "{{input.id}}" + data: + name: "{{input.name ?? steps.get_tag.result.name}}" + color: "{{input.color ?? steps.get_tag.result.color}}" + + - id: get_updated + service: db + action: query_one + params: + query: "SELECT * FROM tags WHERE id = ?" + args: ["{{input.id}}"] + +output: + status: 200 + body: + success: true + message: "Tag mis ร  jour" + data: "{{steps.get_updated.result}}" diff --git a/config/scenarios/prokov/tasks/create.yaml b/config/scenarios/prokov/tasks/create.yaml new file mode 100644 index 0000000..c0d0265 --- /dev/null +++ b/config/scenarios/prokov/tasks/create.yaml @@ -0,0 +1,135 @@ +# Scรฉnario: Crรฉer une tรขche +name: tasks_create +version: "1.0" +description: Crรฉe une nouvelle tรขche + +input: + required: + - project_id + - status_id + - title + optional: + - description + - priority + - date_start + - date_end + - time_estimated + - time_spent + - billing + - position + - tags + defaults: + priority: 5 + time_estimated: 0 + time_spent: 0 + billing: 0 + position: 0 + validation: + project_id: + type: int + status_id: + type: int + title: + type: string + min_length: 1 + max_length: 255 + description: + type: string + max_length: 65535 + priority: + type: int + date_start: + type: string + format: date + date_end: + type: string + format: date + +steps: + - id: check_project + service: db + action: query_one + params: + query: "SELECT id FROM projects WHERE id = ? AND user_id = ?" + args: ["{{input.project_id}}", "{{auth.user_id}}"] + on_error: abort + error_message: "Projet invalide" + error_status: 422 + + - id: check_status + service: db + action: query_one + params: + query: "SELECT id FROM statuses WHERE id = ? AND user_id = ?" + args: ["{{input.status_id}}", "{{auth.user_id}}"] + on_error: abort + error_message: "Statut invalide" + error_status: 422 + + - id: insert_task + service: db + action: insert + params: + table: tasks + data: + user_id: "{{auth.user_id}}" + project_id: "{{input.project_id}}" + status_id: "{{input.status_id}}" + title: "{{input.title}}" + description: "{{input.description}}" + priority: "{{input.priority}}" + date_start: "{{input.date_start}}" + date_end: "{{input.date_end}}" + time_estimated: "{{input.time_estimated}}" + time_spent: "{{input.time_spent}}" + billing: "{{input.billing}}" + position: "{{input.position}}" + + - id: sync_tags + service: db + action: exec + condition: "{{input.tags != null && input.tags | length > 0}}" + foreach: "{{input.tags}}" + foreach_as: tag_id + params: + query: | + INSERT INTO task_tags (task_id, tag_id) + SELECT ?, id FROM tags WHERE id = ? AND user_id = ? + args: ["{{steps.insert_task.insert_id}}", "{{tag_id}}", "{{auth.user_id}}"] + + - id: get_task + service: db + action: query_one + params: + query: | + SELECT t.*, p.name as project_name, s.name as status_name, s.color as status_color + FROM tasks t + LEFT JOIN projects p ON t.project_id = p.id + LEFT JOIN statuses s ON t.status_id = s.id + WHERE t.id = ? + args: ["{{steps.insert_task.insert_id}}"] + + - id: get_tags + service: db + action: query + params: + query: | + SELECT t.id, t.name, t.color + FROM tags t + JOIN task_tags tt ON t.id = tt.tag_id + WHERE tt.task_id = ? + args: ["{{steps.insert_task.insert_id}}"] + +output: + status: 201 + body: + success: true + message: "Tรขche crรฉรฉe" + data: + id: "{{steps.get_task.result.id}}" + project_id: "{{steps.get_task.result.project_id}}" + project_name: "{{steps.get_task.result.project_name}}" + status_id: "{{steps.get_task.result.status_id}}" + status_name: "{{steps.get_task.result.status_name}}" + title: "{{steps.get_task.result.title}}" + tags: "{{steps.get_tags.result}}" diff --git a/config/scenarios/prokov/tasks/delete.yaml b/config/scenarios/prokov/tasks/delete.yaml new file mode 100644 index 0000000..774b90a --- /dev/null +++ b/config/scenarios/prokov/tasks/delete.yaml @@ -0,0 +1,36 @@ +# Scรฉnario: Supprimer une tรขche +name: tasks_delete +version: "1.0" +description: Supprime une tรขche + +input: + required: + - id + validation: + id: + type: int + +steps: + - id: check_task + service: db + action: query_one + params: + query: "SELECT id FROM tasks WHERE id = ? AND user_id = ?" + args: ["{{input.id}}", "{{auth.user_id}}"] + on_error: abort + error_message: "Tรขche non trouvรฉe" + error_status: 404 + + - id: delete_task + service: db + action: delete + params: + table: tasks + where: + id: "{{input.id}}" + +output: + status: 200 + body: + success: true + message: "Tรขche supprimรฉe" diff --git a/config/scenarios/prokov/tasks/list.yaml b/config/scenarios/prokov/tasks/list.yaml new file mode 100644 index 0000000..ec0f368 --- /dev/null +++ b/config/scenarios/prokov/tasks/list.yaml @@ -0,0 +1,67 @@ +# Scรฉnario: Liste des tรขches +name: tasks_list +version: "1.0" +description: Retourne les tรขches avec filtres optionnels + +input: + optional: + - project_id + - status_id + - tag_id + - date_start + - date_end + validation: + project_id: + type: int + status_id: + type: int + tag_id: + type: int + date_start: + type: string + format: date + date_end: + type: string + format: date + +steps: + - id: get_tasks + service: db + action: query + params: + query: | + SELECT t.*, + p.name as project_name, + s.name as status_name, + s.color as status_color, + GROUP_CONCAT(tg.id) as tag_ids, + GROUP_CONCAT(tg.name) as tag_names, + GROUP_CONCAT(tg.color) as tag_colors + FROM tasks t + LEFT JOIN projects p ON t.project_id = p.id + LEFT JOIN statuses s ON t.status_id = s.id + LEFT JOIN task_tags tt ON t.id = tt.task_id + LEFT JOIN tags tg ON tt.tag_id = tg.id + WHERE t.user_id = ? + AND (? IS NULL OR t.project_id = ?) + AND (? IS NULL OR t.status_id = ?) + AND (? IS NULL OR t.date_start >= ?) + AND (? IS NULL OR t.date_end <= ?) + GROUP BY t.id + ORDER BY t.position ASC, t.priority DESC, t.created_at DESC + args: + - "{{auth.user_id}}" + - "{{input.project_id}}" + - "{{input.project_id}}" + - "{{input.status_id}}" + - "{{input.status_id}}" + - "{{input.date_start}}" + - "{{input.date_start}}" + - "{{input.date_end}}" + - "{{input.date_end}}" + +output: + status: 200 + body: + success: true + data: "{{steps.get_tasks.result | parse_tags}}" diff --git a/config/scenarios/prokov/tasks/show.yaml b/config/scenarios/prokov/tasks/show.yaml new file mode 100644 index 0000000..cad9138 --- /dev/null +++ b/config/scenarios/prokov/tasks/show.yaml @@ -0,0 +1,61 @@ +# Scรฉnario: Dรฉtail d'une tรขche +name: tasks_show +version: "1.0" +description: Retourne une tรขche avec ses tags + +input: + required: + - id + validation: + id: + type: int + +steps: + - id: get_task + service: db + action: query_one + params: + query: | + SELECT t.*, p.name as project_name, s.name as status_name, s.color as status_color + FROM tasks t + LEFT JOIN projects p ON t.project_id = p.id + LEFT JOIN statuses s ON t.status_id = s.id + WHERE t.id = ? AND t.user_id = ? + args: ["{{input.id}}", "{{auth.user_id}}"] + on_error: abort + error_message: "Tรขche non trouvรฉe" + error_status: 404 + + - id: get_tags + service: db + action: query + params: + query: | + SELECT t.id, t.name, t.color + FROM tags t + JOIN task_tags tt ON t.id = tt.tag_id + WHERE tt.task_id = ? + args: ["{{input.id}}"] + +output: + status: 200 + body: + success: true + data: + id: "{{steps.get_task.result.id}}" + project_id: "{{steps.get_task.result.project_id}}" + project_name: "{{steps.get_task.result.project_name}}" + status_id: "{{steps.get_task.result.status_id}}" + status_name: "{{steps.get_task.result.status_name}}" + status_color: "{{steps.get_task.result.status_color}}" + title: "{{steps.get_task.result.title}}" + description: "{{steps.get_task.result.description}}" + priority: "{{steps.get_task.result.priority}}" + date_start: "{{steps.get_task.result.date_start}}" + date_end: "{{steps.get_task.result.date_end}}" + time_estimated: "{{steps.get_task.result.time_estimated}}" + time_spent: "{{steps.get_task.result.time_spent}}" + billing: "{{steps.get_task.result.billing}}" + position: "{{steps.get_task.result.position}}" + created_at: "{{steps.get_task.result.created_at}}" + tags: "{{steps.get_tags.result}}" diff --git a/config/scenarios/prokov/tasks/update.yaml b/config/scenarios/prokov/tasks/update.yaml new file mode 100644 index 0000000..4e916df --- /dev/null +++ b/config/scenarios/prokov/tasks/update.yaml @@ -0,0 +1,140 @@ +# Scรฉnario: Modifier une tรขche +name: tasks_update +version: "1.0" +description: Met ร  jour une tรขche existante + +input: + required: + - id + optional: + - project_id + - status_id + - title + - description + - priority + - date_start + - date_end + - time_estimated + - time_spent + - billing + - position + - tags + validation: + id: + type: int + project_id: + type: int + status_id: + type: int + title: + type: string + min_length: 1 + max_length: 255 + +steps: + - id: get_task + service: db + action: query_one + params: + query: "SELECT * FROM tasks WHERE id = ? AND user_id = ?" + args: ["{{input.id}}", "{{auth.user_id}}"] + on_error: abort + error_message: "Tรขche non trouvรฉe" + error_status: 404 + + - id: check_project + service: db + action: query_one + condition: "{{input.project_id != null}}" + params: + query: "SELECT id FROM projects WHERE id = ? AND user_id = ?" + args: ["{{input.project_id}}", "{{auth.user_id}}"] + on_error: abort + error_message: "Projet invalide" + error_status: 422 + + - id: check_status + service: db + action: query_one + condition: "{{input.status_id != null}}" + params: + query: "SELECT id FROM statuses WHERE id = ? AND user_id = ?" + args: ["{{input.status_id}}", "{{auth.user_id}}"] + on_error: abort + error_message: "Statut invalide" + error_status: 422 + + - id: update_task + service: db + action: update + params: + table: tasks + where: + id: "{{input.id}}" + data: + project_id: "{{input.project_id ?? steps.get_task.result.project_id}}" + status_id: "{{input.status_id ?? steps.get_task.result.status_id}}" + title: "{{input.title ?? steps.get_task.result.title}}" + description: "{{input.description ?? steps.get_task.result.description}}" + priority: "{{input.priority ?? steps.get_task.result.priority}}" + date_start: "{{input.date_start ?? steps.get_task.result.date_start}}" + date_end: "{{input.date_end ?? steps.get_task.result.date_end}}" + time_estimated: "{{input.time_estimated ?? steps.get_task.result.time_estimated}}" + time_spent: "{{input.time_spent ?? steps.get_task.result.time_spent}}" + billing: "{{input.billing ?? steps.get_task.result.billing}}" + position: "{{input.position ?? steps.get_task.result.position}}" + + - id: clear_tags + service: db + action: delete + condition: "{{input.tags != null}}" + params: + table: task_tags + where: + task_id: "{{input.id}}" + + - id: sync_tags + service: db + action: exec + condition: "{{input.tags != null && input.tags | length > 0}}" + foreach: "{{input.tags}}" + foreach_as: tag_id + params: + query: | + INSERT INTO task_tags (task_id, tag_id) + SELECT ?, id FROM tags WHERE id = ? AND user_id = ? + args: ["{{input.id}}", "{{tag_id}}", "{{auth.user_id}}"] + + - id: get_updated + service: db + action: query_one + params: + query: | + SELECT t.*, p.name as project_name, s.name as status_name, s.color as status_color + FROM tasks t + LEFT JOIN projects p ON t.project_id = p.id + LEFT JOIN statuses s ON t.status_id = s.id + WHERE t.id = ? + args: ["{{input.id}}"] + + - id: get_tags + service: db + action: query + params: + query: | + SELECT t.id, t.name, t.color + FROM tags t + JOIN task_tags tt ON t.id = tt.tag_id + WHERE tt.task_id = ? + args: ["{{input.id}}"] + +output: + status: 200 + body: + success: true + message: "Tรขche mise ร  jour" + data: + id: "{{steps.get_updated.result.id}}" + title: "{{steps.get_updated.result.title}}" + status_name: "{{steps.get_updated.result.status_name}}" + tags: "{{steps.get_tags.result}}" diff --git a/config/sogoctl.yaml b/config/sogoctl.yaml new file mode 100644 index 0000000..a55d033 --- /dev/null +++ b/config/sogoctl.yaml @@ -0,0 +1,29 @@ +# Configuration du superviseur sogoctl + +supervisor: + health_interval: 10s + restart_delay: 2s + max_restarts: 5 + +services: + sogoms-db: + binary: /opt/sogoms/bin/sogoms-db + args: + - "-config" + - "/config" + - "-socket" + - "/run/sogoms-db.1.sock" + health_socket: /run/sogoms-db.1.sock + + sogoway: + binary: /opt/sogoms/bin/sogoway + args: + - "-config" + - "/config" + - "-port" + - "8080" + - "-db-socket" + - "/run/sogoms-db.1.sock" + health_url: http://localhost:8080/health + depends_on: + - sogoms-db diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..ac747ae --- /dev/null +++ b/deploy.sh @@ -0,0 +1,154 @@ +#!/bin/bash + +# Script de dรฉploiement pour SOGOMS +# Version: 1.0 (15 dรฉcembre 2025) +# Auteur: Pierre (avec l'aide de Claude) + +set -euo pipefail + +# Configuration SSH +JUMP_USER="root" +JUMP_HOST="195.154.80.116" +JUMP_PORT="22" +JUMP_KEY="/home/pierre/.ssh/id_rsa_mbpi" + +# Configuration Incus +INCUS_PROJECT="default" +INCUS_CONTAINER="gw3" +CONTAINER_IP="13.23.33.5" + +# Chemins sur le container +REMOTE_BIN="/opt/sogoms/bin" +REMOTE_CONFIG="/config" +REMOTE_SECRETS="/secrets" + +# Couleurs pour les messages +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# Fonctions d'affichage +echo_step() { + echo -e "${GREEN}==>${NC} $1" +} + +echo_info() { + echo -e "${BLUE}Info:${NC} $1" +} + +echo_warning() { + echo -e "${YELLOW}Warning:${NC} $1" +} + +echo_error() { + echo -e "${RED}Error:${NC} $1" + exit 1 +} + +# Rรฉpertoire du script +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +# Vรฉrification de l'environnement +echo_step "Verifying environment..." +echo_info "Deploying SOGOMS to container $INCUS_CONTAINER ($CONTAINER_IP)" +echo_info "Jump host: $JUMP_HOST" + +if [ ! -d "cmd/sogoms/db" ] || [ ! -d "cmd/sogoway" ] || [ ! -d "cmd/sogoctl" ]; then + echo_error "Source directories missing - are you in the sogoms directory?" +fi + +if [ ! -d "config/routes" ]; then + echo_error "config/routes missing" +fi + +# Commande SSH vers IN3 +SSH_CMD="ssh -i ${JUMP_KEY} -p ${JUMP_PORT} ${JUMP_USER}@${JUMP_HOST}" +SCP_CMD="scp -i ${JUMP_KEY} -P ${JUMP_PORT}" + +# Lire la version +VERSION=$(cat VERSION | tr -d '\n') +BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S') +LDFLAGS="-X sogoms.com/internal/version.Version=${VERSION} -X sogoms.com/internal/version.BuildTime=${BUILD_TIME}" + +# ร‰tape 1: Build des binaires +echo_step "Building binaries v${VERSION} (linux/amd64)..." +mkdir -p bin + +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoms-db ./cmd/sogoms/db || echo_error "Failed to build sogoms-db" +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoway ./cmd/sogoway || echo_error "Failed to build sogoway" +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoctl ./cmd/sogoctl || echo_error "Failed to build sogoctl" + +echo_info "Built: sogoms-db, sogoway, sogoctl (v${VERSION})" + +# ร‰tape 2: Crรฉer les archives +echo_step "Creating archives..." +TIMESTAMP=$(date +%s) +BIN_ARCHIVE="sogoms-bin-${TIMESTAMP}.tar.gz" +CONFIG_ARCHIVE="sogoms-config-${TIMESTAMP}.tar.gz" + +tar -czf "/tmp/${BIN_ARCHIVE}" -C bin . || echo_error "Failed to create bin archive" +tar -czf "/tmp/${CONFIG_ARCHIVE}" -C config . || echo_error "Failed to create config archive" + +BIN_SIZE=$(du -h "/tmp/${BIN_ARCHIVE}" | cut -f1) +CONFIG_SIZE=$(du -h "/tmp/${CONFIG_ARCHIVE}" | cut -f1) +echo_info "Binaries archive: $BIN_SIZE" +echo_info "Config archive: $CONFIG_SIZE" + +# ร‰tape 3: Copier vers IN3 +echo_step "Copying archives to jump server (IN3)..." +$SCP_CMD "/tmp/${BIN_ARCHIVE}" "${JUMP_USER}@${JUMP_HOST}:/tmp/" || echo_error "Failed to copy bin archive" +$SCP_CMD "/tmp/${CONFIG_ARCHIVE}" "${JUMP_USER}@${JUMP_HOST}:/tmp/" || echo_error "Failed to copy config archive" + +# ร‰tape 4: Dรฉployer dans le container +echo_step "Deploying to Incus container ($INCUS_CONTAINER)..." +$SSH_CMD " + set -euo pipefail + + echo '๐Ÿ“ฆ Switching to Incus project...' + incus project switch ${INCUS_PROJECT} || exit 1 + + echo '๐Ÿ“ฆ Pushing archives to container...' + incus file push /tmp/${BIN_ARCHIVE} ${INCUS_CONTAINER}/tmp/ || exit 1 + incus file push /tmp/${CONFIG_ARCHIVE} ${INCUS_CONTAINER}/tmp/ || exit 1 + + echo '๐Ÿ“ Deploying binaries...' + incus exec ${INCUS_CONTAINER} -- mkdir -p ${REMOTE_BIN} + incus exec ${INCUS_CONTAINER} -- tar -xzvf /tmp/${BIN_ARCHIVE} -C ${REMOTE_BIN}/ + incus exec ${INCUS_CONTAINER} -- chmod 755 ${REMOTE_BIN}/sogoms-db ${REMOTE_BIN}/sogoway ${REMOTE_BIN}/sogoctl + + echo '๐Ÿ“ Deploying config...' + incus exec ${INCUS_CONTAINER} -- mkdir -p ${REMOTE_CONFIG}/routes ${REMOTE_CONFIG}/scenarios + incus exec ${INCUS_CONTAINER} -- tar -xzf /tmp/${CONFIG_ARCHIVE} -C ${REMOTE_CONFIG}/ + + echo '๐Ÿ“ Setting up run directory...' + incus exec ${INCUS_CONTAINER} -- mkdir -p /run + + echo '๐Ÿงน Cleanup...' + incus exec ${INCUS_CONTAINER} -- rm -f /tmp/${BIN_ARCHIVE} /tmp/${CONFIG_ARCHIVE} + rm -f /tmp/${BIN_ARCHIVE} /tmp/${CONFIG_ARCHIVE} +" + +# Nettoyage local +rm -f "/tmp/${BIN_ARCHIVE}" "/tmp/${CONFIG_ARCHIVE}" + +# Rรฉsumรฉ final +echo_step "Deployment completed successfully!" +echo "" +echo_info "SOGOMS v${VERSION} deployed" +echo_info " Host: IN3 ($JUMP_HOST)" +echo_info " Container: $INCUS_CONTAINER ($CONTAINER_IP)" +echo_info " Binaries: $REMOTE_BIN" +echo_info " Config: $REMOTE_CONFIG" +echo_info " Deployment time: $(date)" +echo "" +echo_warning "Next steps on gw3:" +echo_info " 1. Edit /secrets/prokov_db_pass with real DB password" +echo_info " 2. Start services: /opt/sogoms/bin/sogoctl" +echo "" +echo_info "To connect: ssh in3 -t 'incus exec $INCUS_CONTAINER -- sh'" + +# Journaliser le dรฉploiement +echo "$(date '+%Y-%m-%d %H:%M:%S') - SOGOMS v${VERSION} deployed to ${INCUS_CONTAINER} (${CONTAINER_IP})" >> ~/.sogoms_deploy_history diff --git a/go.mod b/go.mod new file mode 100755 index 0000000..970f8f9 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module sogoms.com + +go 1.24.0 + +toolchain go1.24.11 + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect + golang.org/x/crypto v0.46.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..42dcdcd --- /dev/null +++ b/go.sum @@ -0,0 +1,9 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..624916e --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,136 @@ +// Package auth gรจre l'authentification JWT et les mots de passe. +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +var ( + ErrInvalidToken = errors.New("invalid token") + ErrExpiredToken = errors.New("token expired") +) + +// Claims reprรฉsente les claims du JWT. +type Claims struct { + Sub int64 `json:"sub"` // User ID + Email string `json:"email"` // Email + Name string `json:"name"` // Nom + App string `json:"app"` // Application ID + Exp int64 `json:"exp"` // Expiration (Unix timestamp) + Iat int64 `json:"iat"` // Issued at +} + +// JWT gรจre la gรฉnรฉration et validation des tokens. +type JWT struct { + secret []byte + expiration time.Duration +} + +// NewJWT crรฉe un nouveau gestionnaire JWT. +func NewJWT(secret string, expiration time.Duration) *JWT { + return &JWT{ + secret: []byte(secret), + expiration: expiration, + } +} + +// Generate gรฉnรจre un nouveau token JWT. +func (j *JWT) Generate(userID int64, email, name, appID string) (string, error) { + now := time.Now() + + claims := Claims{ + Sub: userID, + Email: email, + Name: name, + App: appID, + Iat: now.Unix(), + Exp: now.Add(j.expiration).Unix(), + } + + return j.encode(claims) +} + +// Validate valide un token et retourne les claims. +func (j *JWT) Validate(token string) (*Claims, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, ErrInvalidToken + } + + // Vรฉrifier la signature + signatureInput := parts[0] + "." + parts[1] + expectedSig := j.sign(signatureInput) + if parts[2] != expectedSig { + return nil, ErrInvalidToken + } + + // Dรฉcoder les claims + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, ErrInvalidToken + } + + var claims Claims + if err := json.Unmarshal(claimsJSON, &claims); err != nil { + return nil, ErrInvalidToken + } + + // Vรฉrifier l'expiration + if time.Now().Unix() > claims.Exp { + return nil, ErrExpiredToken + } + + return &claims, nil +} + +// encode encode les claims en JWT. +func (j *JWT) encode(claims Claims) (string, error) { + // Header + header := map[string]string{ + "alg": "HS256", + "typ": "JWT", + } + headerJSON, _ := json.Marshal(header) + headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) + + // Payload + claimsJSON, err := json.Marshal(claims) + if err != nil { + return "", err + } + claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON) + + // Signature + signatureInput := headerB64 + "." + claimsB64 + signature := j.sign(signatureInput) + + return signatureInput + "." + signature, nil +} + +// sign signe les donnรฉes avec HMAC-SHA256. +func (j *JWT) sign(data string) string { + h := hmac.New(sha256.New, j.secret) + h.Write([]byte(data)) + return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) +} + +// ExtractToken extrait le token du header Authorization. +func ExtractToken(authHeader string) (string, error) { + if authHeader == "" { + return "", fmt.Errorf("missing authorization header") + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + return "", fmt.Errorf("invalid authorization header format") + } + + return parts[1], nil +} diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..c50e013 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,20 @@ +package auth + +import ( + "golang.org/x/crypto/bcrypt" +) + +// HashPassword gรฉnรจre un hash bcrypt du mot de passe. +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +// VerifyPassword vรฉrifie si le mot de passe correspond au hash. +func VerifyPassword(hash, password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..43c1fb3 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,182 @@ +// Package config gรจre le chargement des configurations YAML. +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "gopkg.in/yaml.v3" +) + +// AppConfig reprรฉsente la configuration d'une application cliente. +type AppConfig struct { + App string `yaml:"app"` + Version string `yaml:"version"` + BasePath string `yaml:"base_path"` + Hosts []string `yaml:"hosts"` + Database Database `yaml:"database"` + Auth Auth `yaml:"auth"` + Routes []Route `yaml:"routes"` +} + +// Auth contient la configuration d'authentification. +type Auth struct { + JWTSecretFile string `yaml:"jwt_secret_file"` + JWTExpiry string `yaml:"jwt_expiry"` + jwtSecret string // Chargรฉ depuis le fichier +} + +// JWTSecret retourne le secret JWT (chargรฉ depuis le fichier). +func (a *Auth) JWTSecret() string { + return a.jwtSecret +} + +// Database contient la configuration de connexion ร  la base de donnรฉes. +type Database struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + User string `yaml:"user"` + PasswordFile string `yaml:"password_file"` + Name string `yaml:"name"` + password string // Chargรฉ depuis le fichier +} + +// Password retourne le mot de passe (chargรฉ depuis le fichier). +func (d *Database) Password() string { + return d.password +} + +// DSN retourne la chaรฎne de connexion MySQL/MariaDB. +func (d *Database) DSN() string { + return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&charset=utf8mb4", + d.User, d.password, d.Host, d.Port, d.Name) +} + +// Route reprรฉsente une route API. +type Route struct { + Path string `yaml:"path"` + Method string `yaml:"method"` + Scenario string `yaml:"scenario"` + Auth *bool `yaml:"auth,omitempty"` +} + +// Registry stocke les configurations des applications. +type Registry struct { + configDir string + apps map[string]*AppConfig // Par app_id + byHost map[string]*AppConfig // Par hostname + mu sync.RWMutex +} + +// NewRegistry crรฉe un nouveau registre de configurations. +func NewRegistry(configDir string) *Registry { + return &Registry{ + configDir: configDir, + apps: make(map[string]*AppConfig), + byHost: make(map[string]*AppConfig), + } +} + +// Load charge toutes les configurations depuis le rรฉpertoire routes. +func (r *Registry) Load() error { + r.mu.Lock() + defer r.mu.Unlock() + + routesDir := filepath.Join(r.configDir, "routes") + entries, err := os.ReadDir(routesDir) + if err != nil { + return fmt.Errorf("read routes dir: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") { + continue + } + + path := filepath.Join(routesDir, entry.Name()) + cfg, err := r.loadAppConfig(path) + if err != nil { + return fmt.Errorf("load %s: %w", entry.Name(), err) + } + + r.apps[cfg.App] = cfg + for _, host := range cfg.Hosts { + r.byHost[host] = cfg + } + } + + return nil +} + +// loadAppConfig charge une configuration d'application depuis un fichier YAML. +func (r *Registry) loadAppConfig(path string) (*AppConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var cfg AppConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + + // Charger le mot de passe DB depuis le fichier + if cfg.Database.PasswordFile != "" { + passData, err := os.ReadFile(cfg.Database.PasswordFile) + if err != nil { + return nil, fmt.Errorf("read db password file: %w", err) + } + cfg.Database.password = strings.TrimSpace(string(passData)) + } + + // Charger le secret JWT depuis le fichier + if cfg.Auth.JWTSecretFile != "" { + secretData, err := os.ReadFile(cfg.Auth.JWTSecretFile) + if err != nil { + return nil, fmt.Errorf("read jwt secret file: %w", err) + } + cfg.Auth.jwtSecret = strings.TrimSpace(string(secretData)) + } + + // Port DB par dรฉfaut + if cfg.Database.Port == 0 { + cfg.Database.Port = 3306 + } + + // Expiry JWT par dรฉfaut + if cfg.Auth.JWTExpiry == "" { + cfg.Auth.JWTExpiry = "24h" + } + + return &cfg, nil +} + +// GetByApp retourne la configuration d'une application par son ID. +func (r *Registry) GetByApp(appID string) (*AppConfig, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + cfg, ok := r.apps[appID] + return cfg, ok +} + +// GetByHost retourne la configuration d'une application par son hostname. +func (r *Registry) GetByHost(host string) (*AppConfig, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + cfg, ok := r.byHost[host] + return cfg, ok +} + +// Apps retourne la liste des IDs d'applications chargรฉes. +func (r *Registry) Apps() []string { + r.mu.RLock() + defer r.mu.RUnlock() + apps := make([]string, 0, len(r.apps)) + for app := range r.apps { + apps = append(apps, app) + } + return apps +} diff --git a/internal/protocol/client.go b/internal/protocol/client.go new file mode 100644 index 0000000..b3cbdb7 --- /dev/null +++ b/internal/protocol/client.go @@ -0,0 +1,169 @@ +package protocol + +import ( + "context" + "fmt" + "net" + "sync" + "time" +) + +// Client permet d'appeler un microservice via Unix socket. +type Client struct { + socketPath string + conn net.Conn + mu sync.Mutex +} + +// NewClient crรฉe un nouveau client. +func NewClient(socketPath string) *Client { + return &Client{ + socketPath: socketPath, + } +} + +// Connect รฉtablit la connexion au socket. +func (c *Client) Connect() error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.conn != nil { + return nil // Dรฉjร  connectรฉ + } + + conn, err := net.Dial("unix", c.socketPath) + if err != nil { + return fmt.Errorf("dial %s: %w", c.socketPath, err) + } + c.conn = conn + return nil +} + +// Close ferme la connexion. +func (c *Client) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.conn != nil { + err := c.conn.Close() + c.conn = nil + return err + } + return nil +} + +// Call envoie une requรชte et attend la rรฉponse. +func (c *Client) Call(ctx context.Context, req *Request) (*Response, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.conn == nil { + return nil, fmt.Errorf("not connected") + } + + // Timeout + timeout := time.Duration(req.TimeoutMs) * time.Millisecond + if timeout == 0 { + timeout = 5 * time.Second + } + deadline := time.Now().Add(timeout) + c.conn.SetDeadline(deadline) + + // Encoder et envoyer la requรชte + data, err := Encode(req) + if err != nil { + return nil, fmt.Errorf("encode request: %w", err) + } + + if err := writeMessage(c.conn, data); err != nil { + c.conn = nil // Connexion cassรฉe + return nil, fmt.Errorf("write request: %w", err) + } + + // Lire la rรฉponse + respData, err := readMessage(c.conn) + if err != nil { + c.conn = nil // Connexion cassรฉe + return nil, fmt.Errorf("read response: %w", err) + } + + resp, err := DecodeResponse(respData) + if err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + + return resp, nil +} + +// CallAction raccourci pour appeler une action. +func (c *Client) CallAction(ctx context.Context, action string, params map[string]any) (*Response, error) { + req := NewRequest(action, params) + return c.Call(ctx, req) +} + +// Pool gรจre un pool de connexions vers un service. +type Pool struct { + socketPath string + clients chan *Client + maxSize int +} + +// NewPool crรฉe un pool de connexions. +func NewPool(socketPath string, size int) *Pool { + return &Pool{ + socketPath: socketPath, + clients: make(chan *Client, size), + maxSize: size, + } +} + +// Get obtient un client du pool. +func (p *Pool) Get() (*Client, error) { + select { + case client := <-p.clients: + return client, nil + default: + // Crรฉer un nouveau client + client := NewClient(p.socketPath) + if err := client.Connect(); err != nil { + return nil, err + } + return client, nil + } +} + +// Put remet un client dans le pool. +func (p *Pool) Put(client *Client) { + select { + case p.clients <- client: + // OK, remis dans le pool + default: + // Pool plein, fermer le client + client.Close() + } +} + +// Call obtient un client, exรฉcute l'appel, et remet le client. +func (p *Pool) Call(ctx context.Context, req *Request) (*Response, error) { + client, err := p.Get() + if err != nil { + return nil, err + } + + resp, err := client.Call(ctx, req) + if err != nil { + client.Close() + return nil, err + } + + p.Put(client) + return resp, nil +} + +// Close ferme tous les clients du pool. +func (p *Pool) Close() { + close(p.clients) + for client := range p.clients { + client.Close() + } +} diff --git a/internal/protocol/message.go b/internal/protocol/message.go new file mode 100644 index 0000000..8eeca3c --- /dev/null +++ b/internal/protocol/message.go @@ -0,0 +1,90 @@ +// Package protocol dรฉfinit le protocole de communication IPC via Unix sockets. +// Format: 4 bytes (big-endian length) + JSON payload +package protocol + +import ( + "encoding/json" + "time" +) + +// Request reprรฉsente une requรชte envoyรฉe ร  un microservice. +type Request struct { + ID string `json:"id"` + Action string `json:"action"` + TenantID string `json:"tenant_id,omitempty"` + Params map[string]any `json:"params,omitempty"` + TimeoutMs int `json:"timeout_ms,omitempty"` +} + +// Response reprรฉsente la rรฉponse d'un microservice. +type Response struct { + ID string `json:"id"` + Status string `json:"status"` // "success" ou "error" + Result any `json:"result,omitempty"` + Error *Error `json:"error,omitempty"` +} + +// Error dรฉtaille une erreur. +type Error struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// NewRequest crรฉe une nouvelle requรชte avec un ID unique. +func NewRequest(action string, params map[string]any) *Request { + return &Request{ + ID: generateID(), + Action: action, + Params: params, + TimeoutMs: 5000, // 5s par dรฉfaut + } +} + +// Success crรฉe une rรฉponse de succรจs. +func Success(reqID string, result any) *Response { + return &Response{ + ID: reqID, + Status: "success", + Result: result, + } +} + +// Failure crรฉe une rรฉponse d'erreur. +func Failure(reqID string, code, message string) *Response { + return &Response{ + ID: reqID, + Status: "error", + Error: &Error{ + Code: code, + Message: message, + }, + } +} + +// Encode sรฉrialise un message en JSON. +func Encode(v any) ([]byte, error) { + return json.Marshal(v) +} + +// DecodeRequest dรฉsรฉrialise une requรชte JSON. +func DecodeRequest(data []byte) (*Request, error) { + var req Request + if err := json.Unmarshal(data, &req); err != nil { + return nil, err + } + return &req, nil +} + +// DecodeResponse dรฉsรฉrialise une rรฉponse JSON. +func DecodeResponse(data []byte) (*Response, error) { + var resp Response + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// generateID gรฉnรจre un ID unique basรฉ sur le timestamp. +func generateID() string { + return "req_" + time.Now().Format("20060102150405.000000") +} diff --git a/internal/protocol/server.go b/internal/protocol/server.go new file mode 100644 index 0000000..2ed0571 --- /dev/null +++ b/internal/protocol/server.go @@ -0,0 +1,174 @@ +package protocol + +import ( + "context" + "encoding/binary" + "fmt" + "io" + "log" + "net" + "os" + "sync" +) + +// Handler traite une requรชte et retourne une rรฉponse. +type Handler func(ctx context.Context, req *Request) *Response + +// Server รฉcoute sur un Unix socket et dispatch les requรชtes. +type Server struct { + socketPath string + handler Handler + listener net.Listener + wg sync.WaitGroup + ctx context.Context + cancel context.CancelFunc +} + +// NewServer crรฉe un nouveau serveur. +func NewServer(socketPath string, handler Handler) *Server { + ctx, cancel := context.WithCancel(context.Background()) + return &Server{ + socketPath: socketPath, + handler: handler, + ctx: ctx, + cancel: cancel, + } +} + +// Start dรฉmarre le serveur. +func (s *Server) Start() error { + // Supprimer le socket existant + if err := os.RemoveAll(s.socketPath); err != nil { + return fmt.Errorf("remove socket: %w", err) + } + + listener, err := net.Listen("unix", s.socketPath) + if err != nil { + return fmt.Errorf("listen: %w", err) + } + s.listener = listener + + // Permissions socket + if err := os.Chmod(s.socketPath, 0660); err != nil { + return fmt.Errorf("chmod socket: %w", err) + } + + log.Printf("[server] listening on %s", s.socketPath) + + go s.acceptLoop() + return nil +} + +// Stop arrรชte le serveur proprement. +func (s *Server) Stop() { + s.cancel() + if s.listener != nil { + s.listener.Close() + } + s.wg.Wait() + os.RemoveAll(s.socketPath) + log.Printf("[server] stopped") +} + +// acceptLoop accepte les connexions entrantes. +func (s *Server) acceptLoop() { + for { + conn, err := s.listener.Accept() + if err != nil { + select { + case <-s.ctx.Done(): + return + default: + log.Printf("[server] accept error: %v", err) + continue + } + } + s.wg.Add(1) + go s.handleConn(conn) + } +} + +// handleConn gรจre une connexion client. +func (s *Server) handleConn(conn net.Conn) { + defer s.wg.Done() + defer conn.Close() + + for { + select { + case <-s.ctx.Done(): + return + default: + } + + // Lire la requรชte + data, err := readMessage(conn) + if err != nil { + if err != io.EOF { + log.Printf("[server] read error: %v", err) + } + return + } + + // Dรฉcoder la requรชte + req, err := DecodeRequest(data) + if err != nil { + resp := Failure("", "DECODE_ERROR", err.Error()) + writeResponse(conn, resp) + continue + } + + // Traiter la requรชte + resp := s.handler(s.ctx, req) + + // Envoyer la rรฉponse + if err := writeResponse(conn, resp); err != nil { + log.Printf("[server] write error: %v", err) + return + } + } +} + +// readMessage lit un message length-prefixed. +func readMessage(r io.Reader) ([]byte, error) { + // Lire les 4 bytes de longueur + lengthBuf := make([]byte, 4) + if _, err := io.ReadFull(r, lengthBuf); err != nil { + return nil, err + } + + length := binary.BigEndian.Uint32(lengthBuf) + if length == 0 || length > 10*1024*1024 { // Max 10MB + return nil, fmt.Errorf("invalid message length: %d", length) + } + + // Lire le payload + data := make([]byte, length) + if _, err := io.ReadFull(r, data); err != nil { + return nil, err + } + + return data, nil +} + +// writeResponse รฉcrit une rรฉponse. +func writeResponse(w io.Writer, resp *Response) error { + data, err := Encode(resp) + if err != nil { + return err + } + return writeMessage(w, data) +} + +// writeMessage รฉcrit un message length-prefixed. +func writeMessage(w io.Writer, data []byte) error { + // ร‰crire la longueur + lengthBuf := make([]byte, 4) + binary.BigEndian.PutUint32(lengthBuf, uint32(len(data))) + if _, err := w.Write(lengthBuf); err != nil { + return err + } + + // ร‰crire le payload + _, err := w.Write(data) + return err +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..d2671d3 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,8 @@ +// Package version contient les informations de version. +package version + +// Set via ldflags: -ldflags "-X sogoms.com/internal/version.Version=1.0.0" +var ( + Version = "dev" + BuildTime = "unknown" +) diff --git a/prompts.txt b/prompts.txt new file mode 100644 index 0000000..e69de29 diff --git a/sogoms-vigil.md b/sogoms-vigil.md new file mode 100755 index 0000000..f08bf7b --- /dev/null +++ b/sogoms-vigil.md @@ -0,0 +1,1213 @@ +# SOGOMS Vigil - Plateforme de Monitoring Multi-Hosts + +> Surveillance temps rรฉel de serveurs et containers avec agents Go et dashboard Flutter + +**Website** : https://vigil.sogoms.com + +--- + +## 1. Vue d'ensemble + +### 1.1 Concept + +**SOGOMS Vigil** est une extension de la plateforme SOGOMS dรฉdiรฉe au monitoring. Elle permet de surveiller en temps rรฉel des serveurs, containers et services via des agents Go lรฉgers, avec une interface Flutter moderne. + +### 1.2 Composants + +| Composant | Description | +|-----------|-------------| +| **Sovigilant** | Agent Go lรฉger installรฉ sur chaque host ร  monitorer | +| **Vigil Server** | Serveur central basรฉ sur l'architecture SOGOMS | +| **Vigil Dashboard** | Application Flutter (Web/Mobile) pour visualisation | + +### 1.3 Fonctionnalitรฉs + +- Monitoring temps rรฉel (CPU, RAM, disk, network) +- Surveillance containers (Incus, Docker) +- Surveillance services (nginx, mariadb, redis...) +- Alerting configurable (email, Slack, webhook) +- Actions distantes (restart, exec, deploy) +- Multi-tenant (chaque client voit ses hosts) +- Historique et graphiques +- Auto-discovery des containers + +--- + +## 2. Architecture + +### 2.1 Vue globale + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ HOSTS MONITORร‰S โ”‚ +โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Serveur A โ”‚ โ”‚ Serveur B โ”‚ โ”‚ Serveur C โ”‚ ... โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚Sovigilant โ”‚ โ”‚ โ”‚ โ”‚Sovigilant โ”‚ โ”‚ โ”‚ โ”‚Sovigilant โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ Agent โ”‚ โ”‚ โ”‚ โ”‚ Agent โ”‚ โ”‚ โ”‚ โ”‚ Agent โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ โ”‚ + โ”‚ gRPC Stream (TLS, bidirectionnel)โ”‚ + โ”‚ โ”‚ โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ VIGIL SERVER (gw3) โ”‚ +โ”‚ 13.23.33.5 - Alpine 3.21 โ”‚ +โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Sogoctl โ”‚ โ”‚ +โ”‚ โ”‚ (Superviseur) โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ Sogoway โ”‚ โ”‚ +โ”‚ โ”‚ โ”œโ”€โ”€ REST API :8080 (Flutter, intรฉgrations) โ”‚ โ”‚ +โ”‚ โ”‚ โ”œโ”€โ”€ WebSocket :8080/ws (temps rรฉel Flutter) โ”‚ โ”‚ +โ”‚ โ”‚ โ””โ”€โ”€ gRPC :9090 (agents Sovigilant) โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ Sogorch โ”‚ โ”‚ +โ”‚ โ”‚ โ””โ”€โ”€ Orchestration actions (restart, deploy, alerting) โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚sogoms- โ”‚sogoms- โ”‚sogoms-db โ”‚sogoms- โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ collect โ”‚ alert โ”‚ โ”‚ action โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚Agrรฉgation โ”‚ร‰valuation โ”‚Stockage โ”‚Exรฉcution โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚mรฉtriques โ”‚rรจgles โ”‚mรฉtriques โ”‚commandes โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚Buffer โ”‚Notificationsโ”‚Historique โ”‚sur agents โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ maria3 โ”‚ โ”‚ redis3 โ”‚ โ”‚ +โ”‚ โ”‚ 13.23.33.4 โ”‚ โ”‚ 13.23.33.6 โ”‚ โ”‚ +โ”‚ โ”‚ MariaDB โ”‚ โ”‚ Redis โ”‚ โ”‚ +โ”‚ โ”‚ (mรฉtriques, โ”‚ โ”‚ (cache, โ”‚ โ”‚ +โ”‚ โ”‚ config) โ”‚ โ”‚ pub/sub) โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ”‚ WebSocket (temps rรฉel) + โ”‚ REST API + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ VIGIL DASHBOARD โ”‚ +โ”‚ Flutter Web / Mobile โ”‚ +โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Dashboard โ”‚ โ”‚ Hosts โ”‚ โ”‚Containers โ”‚ โ”‚ Alertes โ”‚ โ”‚ Actions โ”‚ โ”‚ +โ”‚ โ”‚ Vue globalโ”‚ โ”‚ Dรฉtails โ”‚ โ”‚ Services โ”‚ โ”‚ Historiqueโ”‚ โ”‚ Scripts โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### 2.2 Flux de donnรฉes + +``` +1. Sovigilant collecte mรฉtriques (toutes les 10s) +2. Sovigilant โ†’ gRPC stream โ†’ Sogoway +3. Sogoway โ†’ sogoms-collect (agrรฉgation) +4. sogoms-collect โ†’ sogoms-db (stockage) +5. sogoms-collect โ†’ Redis pub/sub (temps rรฉel) +6. sogoms-alert รฉvalue les rรจgles +7. Si alerte โ†’ sogoms-alert notifie (email, slack...) +8. Redis โ†’ WebSocket โ†’ Flutter (temps rรฉel) +9. Flutter โ† REST API โ† Sogoway (historique, config) +``` + +--- + +## 3. Sovigilant (Agent) + +### 3.1 Caractรฉristiques + +| Aspect | Valeur | +|--------|--------| +| **Taille** | ~5-8 MB (binaire statique) | +| **RAM** | ~10-20 MB en fonctionnement | +| **CPU** | < 1% en moyenne | +| **Intervalle** | Configurable (dรฉfaut 10s) | +| **Protocole** | gRPC avec TLS | +| **Auto-update** | Oui | + +### 3.2 Installation + +```bash +# Tรฉlรฉcharger et installer +curl -fsSL https://vigil.sogoms.com/install.sh | sh + +# Ou manuellement +wget https://vigil.sogoms.com/releases/sovigilant-linux-amd64 +chmod +x sovigilant-linux-amd64 +mv sovigilant-linux-amd64 /usr/local/bin/sovigilant + +# Configurer +sovigilant init --server vigil.sogoms.com --token + +# Dรฉmarrer comme service +sovigilant install-service +systemctl enable sovigilant +systemctl start sovigilant +``` + +### 3.3 Configuration + +```yaml +# /etc/sovigilant/config.yaml + +agent: + id: auto # Auto-gรฉnรฉrรฉ ou dรฉfini + name: "{{hostname}}" # Nom affichรฉ + +server: + address: vigil.sogoms.com:9090 + token: "eyJhbGciOiJIUzI1NiIs..." # Token d'auth (contient tenant_id) + tls: + enabled: true + ca_cert: /etc/sovigilant/ca.pem # Optionnel si CA publique + +collection: + interval: 10s # Intervalle de collecte + + host: + enabled: true + metrics: + - cpu + - memory + - disk + - network + - load + - uptime + + containers: + enabled: true + runtime: auto # auto | incus | docker | podman + metrics: + - cpu + - memory + - disk + - network + - status + + services: + enabled: true + discover: true # Auto-dรฉcouverte + watch: # Services explicites + - nginx + - mariadb + - redis + - php-fpm + custom: # Services custom + - name: myapp + check: systemctl is-active myapp + port: 8080 + + custom_metrics: # Mรฉtriques personnalisรฉes + - name: nginx_connections + command: "nginx -s status | grep 'Active connections' | awk '{print $3}'" + type: gauge + interval: 30s + +logging: + level: info + output: /var/log/sovigilant.log + max_size: 10MB + max_files: 5 + +actions: + enabled: true # Autoriser les actions distantes + allowed: # Liste blanche de commandes + - "systemctl restart *" + - "systemctl status *" + - "docker restart *" + - "incus restart *" + denied: # Liste noire + - "rm -rf *" + - "shutdown*" + - "reboot*" +``` + +### 3.4 Mรฉtriques collectรฉes + +```yaml +# Structure des mรฉtriques envoyรฉes + +host: + timestamp: "2025-01-15T10:30:00Z" + uptime: 864000 + + cpu: + percent: 23.5 + cores: 8 + model: "Intel Xeon E5-2680" + per_core: [20.1, 25.3, 22.0, ...] + + memory: + total: 8589934592 + used: 4293918720 + available: 4296015872 + percent: 50.0 + swap_total: 2147483648 + swap_used: 0 + + disk: + mounts: + - path: / + total: 107374182400 + used: 53687091200 + percent: 50.0 + fs_type: ext4 + - path: /data + total: 536870912000 + used: 107374182400 + percent: 20.0 + fs_type: ext4 + + network: + interfaces: + - name: eth0 + rx_bytes: 123456789012 + tx_bytes: 987654321098 + rx_packets: 12345678 + tx_packets: 9876543 + rx_errors: 0 + tx_errors: 0 + + load: + load_1m: 0.52 + load_5m: 0.48 + load_15m: 0.45 + +containers: + - name: maria3 + runtime: incus + status: running + created: "2025-01-01T00:00:00Z" + image: alpine/3.21 + + cpu: + percent: 5.2 + + memory: + used: 536870912 + limit: 2147483648 + percent: 25.0 + + disk: + used: 10737418240 + + network: + rx_bytes: 12345678 + tx_bytes: 87654321 + + processes: 45 + + - name: gw3 + runtime: incus + status: running + # ... + +services: + - name: nginx + status: active + pid: 1234 + memory: 52428800 + cpu_percent: 0.5 + ports: [80, 443] + uptime: 864000 + + - name: mariadb + status: active + pid: 5678 + memory: 268435456 + cpu_percent: 2.1 + ports: [3306] + connections: 15 + + - name: php-fpm + status: active + pid: 9012 + memory: 134217728 + workers_active: 5 + workers_idle: 15 +``` + +### 3.5 CLI Sovigilant + +```bash +# Statut de l'agent +$ sovigilant status +Agent ID: host-abc123 +Status: connected +Server: vigil.sogoms.com:9090 +Uptime: 5d 12h 30m +Last push: 2s ago +Metrics sent: 45,230 + +# Tester la collecte +$ sovigilant collect --once +Collecting host metrics... OK +Collecting container metrics... OK (5 containers) +Collecting service metrics... OK (4 services) + +# Voir les mรฉtriques actuelles +$ sovigilant metrics +HOST + CPU: 23.5% + Memory: 4.0GB / 8.0GB (50%) + Disk /: 50GB / 100GB (50%) + Load: 0.52 0.48 0.45 + +CONTAINERS + maria3 running CPU: 5.2% MEM: 512MB/2GB + gw3 running CPU: 12.1% MEM: 256MB/1GB + redis3 running CPU: 0.5% MEM: 64MB/512MB + +SERVICES + nginx active PID: 1234 MEM: 50MB + mariadb active PID: 5678 MEM: 256MB + php-fpm active PID: 9012 MEM: 128MB + +# Logs +$ sovigilant logs --follow + +# Mise ร  jour +$ sovigilant update +Current version: 1.2.0 +Latest version: 1.3.0 +Downloading... OK +Installing... OK +Restarting... OK + +# Rรฉinitialiser +$ sovigilant reset +``` + +--- + +## 4. Vigil Server + +### 4.1 Binaires + +| Binaire | Rรดle | Port/Socket | +|---------|------|-------------| +| **sogoctl** | Superviseur | TCP :9000 | +| **sogoway** | API REST + WebSocket + gRPC | TCP :8080, :9090 | +| **sogorch** | Orchestration actions | Unix socket | +| **sogoms-collect** | Agrรฉgation mรฉtriques | Unix socket | +| **sogoms-alert** | ร‰valuation alertes | Unix socket | +| **sogoms-db** | Stockage MariaDB | Unix socket | +| **sogoms-action** | Exรฉcution commandes | Unix socket | + +### 4.2 Sogoway - Endpoints + +#### REST API + +```yaml +# Authentification +POST /api/auth/login # Login, retourne JWT +POST /api/auth/refresh # Refresh token +POST /api/auth/logout # Logout + +# Hosts +GET /api/hosts # Liste des hosts du tenant +GET /api/hosts/{id} # Dรฉtail d'un host +GET /api/hosts/{id}/metrics # Mรฉtriques d'un host +GET /api/hosts/{id}/containers # Containers d'un host +GET /api/hosts/{id}/services # Services d'un host +POST /api/hosts/{id}/action # Exรฉcuter une action + +# Containers +GET /api/containers # Tous les containers +GET /api/containers/{id} # Dรฉtail d'un container +GET /api/containers/{id}/metrics # Mรฉtriques d'un container +POST /api/containers/{id}/action # Action (start/stop/restart) + +# Alertes +GET /api/alerts # Alertes actives +GET /api/alerts/history # Historique des alertes +GET /api/alerts/rules # Rรจgles d'alerte +POST /api/alerts/rules # Crรฉer une rรจgle +PUT /api/alerts/rules/{id} # Modifier une rรจgle +DELETE /api/alerts/rules/{id} # Supprimer une rรจgle +POST /api/alerts/{id}/acknowledge # Acquitter une alerte + +# Mรฉtriques agrรฉgรฉes +GET /api/metrics/overview # Vue globale +GET /api/metrics/history # Historique (query params) + +# Configuration +GET /api/config/tenant # Config du tenant +PUT /api/config/tenant # Modifier config +GET /api/config/notifications # Canaux de notification +PUT /api/config/notifications # Modifier canaux +``` + +#### WebSocket + +```yaml +# Connexion +ws://vigil.sogoms.com:8080/ws?token= + +# Messages serveur โ†’ client +{ + "type": "metrics", + "host_id": "host-abc123", + "timestamp": "2025-01-15T10:30:00Z", + "data": { + "cpu": 23.5, + "memory": 50.0, + "containers": [ + {"name": "gw3", "cpu": 12.1, "memory": 25.0, "status": "running"} + ] + } +} + +{ + "type": "alert", + "id": "alert-xyz789", + "severity": "warning", + "host_id": "host-abc123", + "message": "CPU > 80% depuis 5 minutes", + "timestamp": "2025-01-15T10:30:00Z" +} + +{ + "type": "host_status", + "host_id": "host-abc123", + "status": "offline", # online | offline | degraded + "timestamp": "2025-01-15T10:30:00Z" +} + +# Messages client โ†’ serveur +{ + "type": "subscribe", + "hosts": ["host-abc123", "host-def456"] # Optionnel, dรฉfaut = tous +} + +{ + "type": "unsubscribe", + "hosts": ["host-abc123"] +} +``` + +#### gRPC (Agents) + +```protobuf +// vigil.proto + +syntax = "proto3"; +package vigil; + +service VigilService { + // Stream bidirectionnel : mรฉtriques โ†‘ commandes โ†“ + rpc Connect(stream AgentMessage) returns (stream ServerMessage); + + // Enregistrement initial + rpc Register(RegisterRequest) returns (RegisterResponse); +} + +message AgentMessage { + string agent_id = 1; + oneof payload { + Metrics metrics = 2; + ActionResult action_result = 3; + Heartbeat heartbeat = 4; + } +} + +message ServerMessage { + oneof payload { + Action action = 1; + ConfigUpdate config = 2; + Ack ack = 3; + } +} + +message Metrics { + string timestamp = 1; + HostMetrics host = 2; + repeated ContainerMetrics containers = 3; + repeated ServiceMetrics services = 4; +} + +message Action { + string id = 1; + string type = 2; // restart_service, restart_container, exec + map params = 3; +} + +message ActionResult { + string action_id = 1; + bool success = 2; + string output = 3; + string error = 4; +} +``` + +### 4.3 Sogoms-collect + +Agrรจge et bufferise les mรฉtriques avant stockage. + +```yaml +# Actions +actions: + - ingest # Recevoir mรฉtriques d'un agent + - query_latest # Derniรจres mรฉtriques d'un host + - query_range # Mรฉtriques sur une pรฉriode + - downsample # Agrรฉgation pour historique long terme +``` + +```yaml +# Config +collect: + buffer: + size: 1000 # Mรฉtriques en mรฉmoire avant flush + flush_interval: 5s + + retention: + raw: 7d # Mรฉtriques brutes (10s interval) + hourly: 90d # Agrรฉgรฉes par heure + daily: 2y # Agrรฉgรฉes par jour + + downsample: + enabled: true + schedule: "0 * * * *" # Toutes les heures +``` + +### 4.4 Sogoms-alert + +ร‰value les rรจgles d'alerte et envoie les notifications. + +```yaml +# Actions +actions: + - evaluate # ร‰valuer une mรฉtrique contre les rรจgles + - notify # Envoyer une notification + - acknowledge # Acquitter une alerte + - resolve # Rรฉsoudre une alerte +``` + +```yaml +# Rรจgle d'alerte +rules: + - id: cpu_high + name: "CPU รฉlevรฉ" + condition: "host.cpu.percent > 80" + duration: 5m # Doit รชtre vrai pendant 5min + severity: warning + channels: [email, slack] + message: "CPU ร  {{value}}% sur {{host.name}}" + + - id: disk_critical + name: "Disque critique" + condition: "host.disk.percent > 95" + duration: 1m + severity: critical + channels: [email, slack, pagerduty] + message: "Disque {{mount}} ร  {{value}}% sur {{host.name}}" + + - id: container_down + name: "Container arrรชtรฉ" + condition: "container.status != 'running'" + duration: 30s + severity: critical + channels: [email, slack] + message: "Container {{container.name}} arrรชtรฉ sur {{host.name}}" + + - id: service_down + name: "Service arrรชtรฉ" + condition: "service.status != 'active'" + duration: 1m + severity: critical + channels: [email] + message: "Service {{service.name}} arrรชtรฉ sur {{host.name}}" +``` + +```yaml +# Canaux de notification +channels: + email: + type: smtp + config: + host: smtp.example.com + port: 587 + user: alerts@example.com + from: "SOGOMS Vigil " + + slack: + type: webhook + config: + url: https://hooks.slack.com/services/xxx/yyy/zzz + channel: "#alerts" + + pagerduty: + type: pagerduty + config: + service_key: "xxxxx" + + webhook: + type: http + config: + url: https://my-service.com/webhook + method: POST + headers: + Authorization: "Bearer xxx" +``` + +### 4.5 Sogoms-action + +Exรฉcute des commandes sur les agents distants. + +```yaml +# Actions +actions: + - exec # Exรฉcuter une commande + - restart_service # Redรฉmarrer un service + - restart_container # Redรฉmarrer un container + - deploy # Dรฉployer (via scรฉnario) +``` + +```yaml +# Scรฉnario d'action automatique +name: auto_restart_on_high_memory +trigger: + alert: memory_high + +steps: + - id: restart_php + service: action + action: restart_service + params: + host_id: "{{alert.host_id}}" + service: php-fpm + + - id: notify + service: alert + action: notify + params: + channel: slack + message: "PHP-FPM redรฉmarrรฉ automatiquement sur {{alert.host.name}}" +``` + +--- + +## 5. Schรฉma Base de Donnรฉes + +### 5.1 Tables principales + +```sql +-- Hosts enregistrรฉs +CREATE TABLE hosts ( + id VARCHAR(50) PRIMARY KEY, + tenant_id VARCHAR(50) NOT NULL, + name VARCHAR(100) NOT NULL, + hostname VARCHAR(255), + ip_address VARCHAR(45), + os VARCHAR(100), + arch VARCHAR(20), + agent_version VARCHAR(20), + status ENUM('online', 'offline', 'degraded') DEFAULT 'offline', + last_seen DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + config JSON, + + INDEX idx_tenant (tenant_id), + INDEX idx_status (tenant_id, status) +); + +-- Mรฉtriques hosts (partitionnรฉe) +CREATE TABLE metrics_host ( + id BIGINT AUTO_INCREMENT, + host_id VARCHAR(50) NOT NULL, + timestamp DATETIME NOT NULL, + cpu_percent DECIMAL(5,2), + memory_used BIGINT, + memory_total BIGINT, + disk_used BIGINT, + disk_total BIGINT, + load_1m DECIMAL(5,2), + load_5m DECIMAL(5,2), + load_15m DECIMAL(5,2), + network_rx BIGINT, + network_tx BIGINT, + + PRIMARY KEY (id, timestamp), + INDEX idx_host_time (host_id, timestamp DESC) +) PARTITION BY RANGE (TO_DAYS(timestamp)) ( + PARTITION p_current VALUES LESS THAN (TO_DAYS(DATE_ADD(CURDATE(), INTERVAL 1 DAY))), + PARTITION p_future VALUES LESS THAN MAXVALUE +); + +-- Mรฉtriques containers +CREATE TABLE metrics_container ( + id BIGINT AUTO_INCREMENT, + host_id VARCHAR(50) NOT NULL, + container_name VARCHAR(100) NOT NULL, + timestamp DATETIME NOT NULL, + status VARCHAR(20), + cpu_percent DECIMAL(5,2), + memory_used BIGINT, + memory_limit BIGINT, + disk_used BIGINT, + network_rx BIGINT, + network_tx BIGINT, + + PRIMARY KEY (id, timestamp), + INDEX idx_container_time (host_id, container_name, timestamp DESC) +) PARTITION BY RANGE (TO_DAYS(timestamp)) (...); + +-- Mรฉtriques services +CREATE TABLE metrics_service ( + id BIGINT AUTO_INCREMENT, + host_id VARCHAR(50) NOT NULL, + service_name VARCHAR(100) NOT NULL, + timestamp DATETIME NOT NULL, + status VARCHAR(20), + pid INT, + memory_used BIGINT, + cpu_percent DECIMAL(5,2), + + PRIMARY KEY (id, timestamp), + INDEX idx_service_time (host_id, service_name, timestamp DESC) +) PARTITION BY RANGE (TO_DAYS(timestamp)) (...); + +-- Mรฉtriques agrรฉgรฉes (hourly) +CREATE TABLE metrics_host_hourly ( + host_id VARCHAR(50) NOT NULL, + hour DATETIME NOT NULL, + cpu_avg DECIMAL(5,2), + cpu_max DECIMAL(5,2), + memory_avg BIGINT, + memory_max BIGINT, + disk_avg BIGINT, + network_rx_total BIGINT, + network_tx_total BIGINT, + + PRIMARY KEY (host_id, hour), + INDEX idx_hour (hour) +); + +-- Alertes +CREATE TABLE alerts ( + id VARCHAR(50) PRIMARY KEY, + tenant_id VARCHAR(50) NOT NULL, + host_id VARCHAR(50), + container_name VARCHAR(100), + service_name VARCHAR(100), + rule_id VARCHAR(50) NOT NULL, + severity ENUM('info', 'warning', 'critical') NOT NULL, + status ENUM('firing', 'acknowledged', 'resolved') DEFAULT 'firing', + message TEXT, + value VARCHAR(50), + fired_at DATETIME NOT NULL, + acknowledged_at DATETIME, + acknowledged_by VARCHAR(100), + resolved_at DATETIME, + + INDEX idx_tenant_status (tenant_id, status), + INDEX idx_host (host_id), + INDEX idx_fired (fired_at DESC) +); + +-- Historique des alertes +CREATE TABLE alerts_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + alert_id VARCHAR(50) NOT NULL, + tenant_id VARCHAR(50) NOT NULL, + host_id VARCHAR(50), + rule_id VARCHAR(50) NOT NULL, + severity ENUM('info', 'warning', 'critical') NOT NULL, + message TEXT, + fired_at DATETIME NOT NULL, + resolved_at DATETIME, + duration_seconds INT, + + INDEX idx_tenant_time (tenant_id, fired_at DESC) +); + +-- Rรจgles d'alerte +CREATE TABLE alert_rules ( + id VARCHAR(50) PRIMARY KEY, + tenant_id VARCHAR(50) NOT NULL, + name VARCHAR(100) NOT NULL, + description TEXT, + condition TEXT NOT NULL, + duration_seconds INT DEFAULT 60, + severity ENUM('info', 'warning', 'critical') NOT NULL, + channels JSON NOT NULL, + message_template TEXT, + enabled BOOLEAN DEFAULT TRUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_tenant (tenant_id) +); + +-- Canaux de notification +CREATE TABLE notification_channels ( + id VARCHAR(50) PRIMARY KEY, + tenant_id VARCHAR(50) NOT NULL, + name VARCHAR(100) NOT NULL, + type ENUM('email', 'slack', 'webhook', 'pagerduty') NOT NULL, + config JSON NOT NULL, + enabled BOOLEAN DEFAULT TRUE, + + INDEX idx_tenant (tenant_id) +); + +-- Actions exรฉcutรฉes +CREATE TABLE actions_log ( + id VARCHAR(50) PRIMARY KEY, + tenant_id VARCHAR(50) NOT NULL, + host_id VARCHAR(50) NOT NULL, + user_id VARCHAR(50), + action_type VARCHAR(50) NOT NULL, + params JSON, + status ENUM('pending', 'running', 'success', 'failed') DEFAULT 'pending', + output TEXT, + error TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + completed_at DATETIME, + + INDEX idx_tenant (tenant_id), + INDEX idx_host (host_id) +); + +-- Tokens agents +CREATE TABLE agent_tokens ( + token_hash VARCHAR(64) PRIMARY KEY, + tenant_id VARCHAR(50) NOT NULL, + host_id VARCHAR(50), + name VARCHAR(100), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME, + last_used DATETIME, + + INDEX idx_tenant (tenant_id) +); +``` + +--- + +## 6. Vigil Dashboard (Flutter) + +### 6.1 ร‰crans principaux + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ DASHBOARD [user] โ–ผ โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ 5 Hosts โ”‚ โ”‚ 12 Contain. โ”‚ โ”‚ 2 Alertes โ”‚ โ”‚ 99.9% โ”‚ โ”‚ +โ”‚ โ”‚ Online โ”‚ โ”‚ Running โ”‚ โ”‚ Warning โ”‚ โ”‚ Uptime โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ +โ”‚ HOSTS [+ Add Host] โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ โ— IN3 (prod-1) CPU: โ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘ 45% MEM: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘ 70% โ”‚ โ”‚ +โ”‚ โ”‚ 5 containers, 4 services Disk: โ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ 50% โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ +โ”‚ โ”‚ โ— IN4 (prod-2) CPU: โ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘ 23% MEM: โ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ 50% โ”‚ โ”‚ +โ”‚ โ”‚ 3 containers, 3 services Disk: โ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘ 30% โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ +โ”‚ โ”‚ โ—‹ DEV-1 (offline) Last seen: 5 minutes ago โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ +โ”‚ ALERTES ACTIVES โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ โš  WARNING CPU > 80% on IN3 depuis 10 min [Ack] โ”‚ โ”‚ +โ”‚ โ”‚ โš  WARNING Disk > 85% on IN4/data depuis 2h [Ack] โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ +โ”‚ [Dashboard] [Hosts] [Containers] [Alertes] [Actions] [Settings] โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### 6.2 Dรฉtail Host + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ โ† HOSTS / IN3 (prod-1) [Actions โ–ผ] โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ โ”‚ +โ”‚ STATUS: โ— Online UPTIME: 45 days AGENT: v1.3.0 โ”‚ +โ”‚ OS: Debian 13 IP: 192.168.1.10 ARCH: amd64 โ”‚ +โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ CPU โ”‚ MEMORY โ”‚ โ”‚ +โ”‚ โ”‚ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 45% โ”‚ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 70% โ”‚ โ”‚ +โ”‚ โ”‚ [Graph 24h ~~~~~~~~~~~~~~~] โ”‚ [Graph 24h ~~~~~~~~~~~~~~~] โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ +โ”‚ โ”‚ DISK / โ”‚ DISK /data โ”‚ โ”‚ +โ”‚ โ”‚ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 50% โ”‚ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 30% โ”‚ โ”‚ +โ”‚ โ”‚ 50GB / 100GB โ”‚ 150GB / 500GB โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ +โ”‚ CONTAINERS โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ โ— gw3 CPU: 12% MEM: 256MB/1GB โ–ธ restart โ”‚ logs โ”‚ โ—โ—โ— โ”‚ โ”‚ +โ”‚ โ”‚ โ— maria3 CPU: 5% MEM: 512MB/2GB โ–ธ restart โ”‚ logs โ”‚ โ—โ—โ— โ”‚ โ”‚ +โ”‚ โ”‚ โ— redis3 CPU: 1% MEM: 64MB/512MB โ–ธ restart โ”‚ logs โ”‚ โ—โ—โ— โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ +โ”‚ SERVICES โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ โ— nginx active PID: 1234 MEM: 50MB โ–ธ restart โ”‚ statusโ”‚ โ”‚ +โ”‚ โ”‚ โ— sshd active PID: 567 MEM: 5MB โ–ธ restart โ”‚ statusโ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### 6.3 Architecture Flutter + +``` +lib/ +โ”œโ”€โ”€ main.dart +โ”œโ”€โ”€ app.dart +โ”‚ +โ”œโ”€โ”€ core/ +โ”‚ โ”œโ”€โ”€ api/ +โ”‚ โ”‚ โ”œโ”€โ”€ api_client.dart +โ”‚ โ”‚ โ”œโ”€โ”€ websocket_client.dart +โ”‚ โ”‚ โ””โ”€โ”€ endpoints.dart +โ”‚ โ”œโ”€โ”€ auth/ +โ”‚ โ”‚ โ”œโ”€โ”€ auth_provider.dart +โ”‚ โ”‚ โ””โ”€โ”€ token_storage.dart +โ”‚ โ””โ”€โ”€ theme/ +โ”‚ โ””โ”€โ”€ app_theme.dart +โ”‚ +โ”œโ”€โ”€ features/ +โ”‚ โ”œโ”€โ”€ dashboard/ +โ”‚ โ”‚ โ”œโ”€โ”€ dashboard_screen.dart +โ”‚ โ”‚ โ”œโ”€โ”€ dashboard_provider.dart +โ”‚ โ”‚ โ””โ”€โ”€ widgets/ +โ”‚ โ”‚ โ”œโ”€โ”€ stats_cards.dart +โ”‚ โ”‚ โ”œโ”€โ”€ hosts_list.dart +โ”‚ โ”‚ โ””โ”€โ”€ alerts_panel.dart +โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€ hosts/ +โ”‚ โ”‚ โ”œโ”€โ”€ hosts_screen.dart +โ”‚ โ”‚ โ”œโ”€โ”€ host_detail_screen.dart +โ”‚ โ”‚ โ”œโ”€โ”€ hosts_provider.dart +โ”‚ โ”‚ โ””โ”€โ”€ widgets/ +โ”‚ โ”‚ โ”œโ”€โ”€ host_card.dart +โ”‚ โ”‚ โ”œโ”€โ”€ metrics_chart.dart +โ”‚ โ”‚ โ”œโ”€โ”€ containers_list.dart +โ”‚ โ”‚ โ””โ”€โ”€ services_list.dart +โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€ alerts/ +โ”‚ โ”‚ โ”œโ”€โ”€ alerts_screen.dart +โ”‚ โ”‚ โ”œโ”€โ”€ alert_rules_screen.dart +โ”‚ โ”‚ โ”œโ”€โ”€ alerts_provider.dart +โ”‚ โ”‚ โ””โ”€โ”€ widgets/ +โ”‚ โ”‚ โ”œโ”€โ”€ alert_card.dart +โ”‚ โ”‚ โ””โ”€โ”€ rule_editor.dart +โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€ actions/ +โ”‚ โ”‚ โ”œโ”€โ”€ actions_screen.dart +โ”‚ โ”‚ โ””โ”€โ”€ action_dialog.dart +โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€ settings/ +โ”‚ โ”œโ”€โ”€ settings_screen.dart +โ”‚ โ””โ”€โ”€ notifications_screen.dart +โ”‚ +โ”œโ”€โ”€ models/ +โ”‚ โ”œโ”€โ”€ host.dart +โ”‚ โ”œโ”€โ”€ container.dart +โ”‚ โ”œโ”€โ”€ service.dart +โ”‚ โ”œโ”€โ”€ metrics.dart +โ”‚ โ”œโ”€โ”€ alert.dart +โ”‚ โ””โ”€โ”€ alert_rule.dart +โ”‚ +โ””โ”€โ”€ widgets/ + โ”œโ”€โ”€ metric_gauge.dart + โ”œโ”€โ”€ status_indicator.dart + โ”œโ”€โ”€ time_series_chart.dart + โ””โ”€โ”€ loading_overlay.dart +``` + +--- + +## 7. Dรฉploiement + +### 7.1 Serveur Vigil + +```bash +#!/bin/bash +# deploy/incus/setup-vigil.sh + +# Crรฉer le container +incus launch images:alpine/3.21 vigil + +# IP fixe +incus config device override vigil eth0 ipv4.address=13.23.33.5 + +# Dossiers +incus exec vigil -- mkdir -p /opt/sogoms/bin +incus exec vigil -- mkdir -p /config +incus exec vigil -- mkdir -p /var/log/sogoms + +# Copier les binaires +incus file push dist/sogoctl vigil/opt/sogoms/bin/ +incus file push dist/sogoway vigil/opt/sogoms/bin/ +incus file push dist/sogorch vigil/opt/sogoms/bin/ +incus file push dist/sogoms-* vigil/opt/sogoms/bin/ + +# Copier les configs +incus file push -r config/* vigil/config/ + +# Service +incus exec vigil -- rc-update add sogoms default +incus exec vigil -- rc-service sogoms start +``` + +### 7.2 Agent Sovigilant + +```bash +#!/bin/bash +# Script d'installation agent + +VIGIL_SERVER="${1:-vigil.sogoms.com}" +VIGIL_TOKEN="${2}" + +# Tรฉlรฉcharger +curl -fsSL -o /usr/local/bin/sovigilant \ + "https://${VIGIL_SERVER}/downloads/sovigilant-$(uname -s)-$(uname -m)" +chmod +x /usr/local/bin/sovigilant + +# Configurer +mkdir -p /etc/sovigilant +cat > /etc/sovigilant/config.yaml << EOF +agent: + name: "$(hostname)" +server: + address: ${VIGIL_SERVER}:9090 + token: "${VIGIL_TOKEN}" +collection: + interval: 10s + host: + enabled: true + containers: + enabled: true + runtime: auto + services: + enabled: true + discover: true +EOF + +# Service systemd +cat > /etc/systemd/system/sovigilant.service << EOF +[Unit] +Description=SOGOMS Vigil Agent +After=network.target + +[Service] +ExecStart=/usr/local/bin/sovigilant run +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable sovigilant +systemctl start sovigilant +``` + +--- + +## 8. Structure du Projet + +``` +sogoms/ +โ”œโ”€โ”€ cmd/ +โ”‚ โ”œโ”€โ”€ sogoctl/ +โ”‚ โ”œโ”€โ”€ sogoway/ +โ”‚ โ”œโ”€โ”€ sogorch/ +โ”‚ โ””โ”€โ”€ sogoms/ +โ”‚ โ”œโ”€โ”€ db/ +โ”‚ โ”œโ”€โ”€ pdf/ +โ”‚ โ”œโ”€โ”€ email/ +โ”‚ โ”œโ”€โ”€ storage/ +โ”‚ โ”œโ”€โ”€ collect/ # Vigil: agrรฉgation mรฉtriques +โ”‚ โ”œโ”€โ”€ alert/ # Vigil: alerting +โ”‚ โ””โ”€โ”€ action/ # Vigil: actions distantes +โ”‚ +โ”œโ”€โ”€ cmd/ +โ”‚ โ””โ”€โ”€ sovigilant/ # Agent monitoring +โ”‚ โ””โ”€โ”€ main.go +โ”‚ +โ”œโ”€โ”€ vigil-dashboard/ # Flutter app +โ”‚ โ”œโ”€โ”€ lib/ +โ”‚ โ”œโ”€โ”€ pubspec.yaml +โ”‚ โ””โ”€โ”€ ... +โ”‚ +โ””โ”€โ”€ ... +``` + +--- + +## 9. Roadmap + +### Phase 1 : MVP + +- [ ] Sovigilant : collecte host + containers +- [ ] Sogoway : API REST + gRPC agents +- [ ] Sogoms-collect : agrรฉgation basique +- [ ] Sogoms-db : stockage mรฉtriques +- [ ] Flutter : dashboard + liste hosts + +### Phase 2 : Alerting + +- [ ] Sogoms-alert : รฉvaluation rรจgles +- [ ] Notifications email + Slack +- [ ] Flutter : gestion alertes +- [ ] Historique alertes + +### Phase 3 : Actions + +- [ ] Sogoms-action : commandes distantes +- [ ] Sogorch : scรฉnarios d'action +- [ ] Flutter : interface actions +- [ ] Actions automatiques sur alerte + +### Phase 4 : Avancรฉ + +- [ ] Auto-discovery services +- [ ] Mรฉtriques custom +- [ ] Graphiques avancรฉs +- [ ] Export Prometheus +- [ ] Multi-tenant complet + +--- + +## 10. Rรฉfรฉrences + +- [gRPC Go](https://grpc.io/docs/languages/go/) - Communication agents +- [gopsutil](https://github.com/shirou/gopsutil) - Collecte mรฉtriques systรจme +- [Incus API](https://linuxcontainers.org/incus/docs/main/api/) - Mรฉtriques containers +- [Flutter Charts](https://pub.dev/packages/fl_chart) - Graphiques + +--- + +**Website** : https://vigil.sogoms.com +**Documentation** : https://docs.sogoms.com/vigil +**Agent Downloads** : https://vigil.sogoms.com/downloads + +*Document crรฉรฉ le 2025-01-15 - Version 1.0.0* diff --git a/sogoms.md b/sogoms.md new file mode 100755 index 0000000..6b75482 --- /dev/null +++ b/sogoms.md @@ -0,0 +1,1195 @@ +# SOGOMS - Service Oriented GO MicroServices + +> Architecture modulaire, configurable et multi-tenant pour plateformes SaaS + +**Website** : https://sogoms.com + +--- + +## 1. Vision et Principes + +### 1.1 Concept + +SOGOMS est une architecture de plateforme permettant de construire des applications SaaS modulaires, hautement configurables et multi-tenant. + +L'idรฉe centrale : **une seule plateforme, plusieurs services, plusieurs clients, configurations illimitรฉes.** + +### 1.2 Principes fondamentaux + +| Principe | Description | +|----------|-------------| +| **Modularitรฉ** | Chaque fonctionnalitรฉ = un binaire Go indรฉpendant | +| **Configuration over Code** | Comportements dรฉfinis en YAML, pas en dur | +| **Multi-tenant natif** | Isolation des donnรฉes et configs par client | +| **Orchestration** | Workflows complexes via scรฉnarios paramรฉtrables | +| **Scalabilitรฉ horizontale** | Chaque binaire peut avoir N instances | +| **Simplicitรฉ** | Un container, plusieurs processus supervisรฉs | + +### 1.3 Cas d'usage cibles + +- Plateformes SaaS B2B multi-clients +- Applications mรฉtier avec workflows personnalisables +- Systรจmes de gestion documentaire (PDF, signatures, archivage) +- Plateformes de communication (emails, notifications, SMS) +- Back-offices d'applications mobiles Flutter + +### 1.4 Composants de l'รฉcosystรจme + +| Composant | Description | +|-----------|-------------| +| **SOGOMS** | Plateforme principale (ce document) | +| **SOGOMS Vigil** | Extension monitoring (voir `sogoms-vigil.md`) | + +--- + +## 2. Architecture Globale + +### 2.1 Infrastructure + +``` + Internet + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ IN3 (Dev/Recette) ou IN4 (Prod) โ”‚ +โ”‚ Debian 13 - Host โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ โ”‚ +โ”‚ Nginx Host (:443 TLS โ†’ :80 HTTP) โ”‚ +โ”‚ โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€ api-dev.sogoms.com โ”€โ”€โ†’ gw3:8080 (Dev) โ”‚ +โ”‚ โ”œโ”€โ”€ api.sogoms.com โ”€โ”€โ”€โ”€โ”€โ”€โ†’ gw4:8080 (Prod) โ”‚ +โ”‚ โ””โ”€โ”€ (autres sites) โ”€โ”€โ”€โ”€โ”€โ”€โ†’ [containers PHP existants] โ”‚ +โ”‚ โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Bridge Incus : 13.23.33.0/24 โ”‚ +โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ gw3 - 13.23.33.5 (ou gw4) โ”‚ โ”‚ +โ”‚ โ”‚ Alpine 3.21 โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ Sogoctl (superviseur) โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ PID 1 - Admin :9000 โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ supervise โ”‚ โ”‚ +โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ Sogoway (:8080) โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ–ผ unix socket โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ Sogorch (N instances) โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ–ผ unix sockets โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚sogoms-db โ”‚sogoms-pdfโ”‚sogoms- โ”‚sogoms- โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ (N) โ”‚ (N) โ”‚ email(N) โ”‚ stor.(N) โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ /run/ /config/ /data/storage/ โ”‚ โ”‚ +โ”‚ โ”‚ โ””โ”€โ”€ *.sock โ”œโ”€โ”€ sogoctl.yaml โ””โ”€โ”€ tenants/ โ”‚ โ”‚ +โ”‚ โ”‚ โ”œโ”€โ”€ routes/ โ””โ”€โ”€ {tenant}/ โ”‚ โ”‚ +โ”‚ โ”‚ โ””โ”€โ”€ scenarios/ โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ maria3 โ”‚ โ”‚ redis3 โ”‚ โ”‚ +โ”‚ โ”‚ 13.23.33.4 โ”‚ โ”‚ 13.23.33.6 โ”‚ โ”‚ +โ”‚ โ”‚ MariaDB :3306 โ”‚ โ”‚ Redis :6379 โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### 2.2 Containers Incus + +| Container | IP | Rรดle | Base | +|-----------|-----|------|------| +| **gw3** (dev) / **gw4** (prod) | 13.23.33.5 | Gateway + Orchestrateur + Microservices | Alpine | +| **maria3** / **maria4** | 13.23.33.4 | MariaDB (existant) | Alpine | +| **redis3** / **redis4** | 13.23.33.6 | Cache, sessions, rate-limit (V2+) | Alpine | + +### 2.3 Flux de donnรฉes + +``` +1. Client HTTPS โ†’ Nginx Host (:443) +2. Nginx โ†’ Sogoway (:8080) via proxy HTTP +3. Sogoway โ†’ Valide JWT โ†’ Identifie tenant โ†’ Trouve route +4. Sogoway โ†’ Sogorch (unix socket) +5. Sogorch โ†’ Charge scรฉnario YAML โ†’ Exรฉcute steps +6. Sogorch โ†’ Sogoms-* (unix sockets) pour chaque step +7. Sogoms-* โ†’ Traite (DB, PDF, email...) โ†’ Retourne rรฉsultat +8. Sogorch โ†’ Agrรจge rรฉsultats โ†’ Retourne ร  Sogoway +9. Sogoway โ†’ Nginx โ†’ Client +``` + +### 2.4 Identification des Applications Clientes + +Chaque application cliente de SOGOMS (Prokov, etc.) est identifiรฉe par son **hostname**. + +#### Flow d'identification + +``` +1. Requรชte arrive : GET https://prokov.unikoffice.com/api/auth/me +2. Nginx Host โ†’ Sogoway (:8080) avec Header "Host: prokov.unikoffice.com" +3. Sogoway parcourt config/routes/*.yaml +4. Sogoway trouve prokov.yaml car "prokov.unikoffice.com" est dans ses "hosts" +5. Sogoway identifie app_id = "prokov" +6. Pour chaque appel ร  sogoms-db, Sogoway inclut app_id dans la requรชte +7. Sogoms-db charge config/routes/prokov.yaml โ†’ section "database" +8. Sogoms-db exรฉcute la requรชte SQL sur la DB de l'application +``` + +#### Configuration obligatoire par application + +Chaque fichier `config/routes/{app}.yaml` DOIT contenir : + +```yaml +app: prokov # Identifiant unique +hosts: # Hostnames associรฉs + - prokov.unikoffice.com + - prokov.sogoms.com + +database: # Config DB isolรฉe + host: 13.23.33.4 + port: 3306 + user: prokov # User DB dรฉdiรฉ + password_file: /secrets/prokov_db_pass + name: prokov # Base de donnรฉes dรฉdiรฉe +``` + +#### Principe d'isolation + +| ร‰lรฉment | Isolation | +|---------|-----------| +| Base de donnรฉes | Une DB par application | +| User MariaDB | Un user par application | +| Credentials | Fichier sรฉparรฉ par application | +| Donnรฉes | Aucun partage entre applications | + +--- + +## 3. Binaires Go + +### 3.1 Vue d'ensemble + +| Binaire | Rรดle | Instances | Port/Socket | +|---------|------|-----------|-------------| +| **sogoctl** | Superviseur, admin, monitoring | 1 | TCP :9000 | +| **sogoway** | HTTP Gateway, auth, routing | 1 | TCP :8080 | +| **sogorch** | Orchestrateur, exรฉcute scรฉnarios | N | Unix socket | +| **sogoms-db** | Service accรจs MariaDB | N | Unix socket | +| **sogoms-pdf** | Service gรฉnรฉration PDF | N | Unix socket | +| **sogoms-email** | Service envoi emails | N | Unix socket | +| **sogoms-storage** | Service gestion fichiers | N | Unix socket | + +### 3.2 Sogoctl (Superviseur) + +Sogoctl est le processus principal (PID 1) qui supervise tous les autres binaires. + +#### Responsabilitรฉs + +- Dรฉmarrer/arrรชter les binaires au boot +- Health checks pรฉriodiques +- Redรฉmarrage automatique en cas de crash +- Load balancing round-robin entre instances +- API d'administration (stats, scaling, logs) +- Registry des instances actives + +#### Configuration + +```yaml +# /config/sogoctl.yaml + +supervisor: + socket: /run/sogoctl.sock + admin_port: 9000 + health_interval: 5s + restart_delay: 1s + max_restarts: 5 + +logging: + level: info + output: /var/log/sogoms/sogoctl.log + +services: + sogoway: + binary: /opt/sogoms/bin/sogoway + instances: 1 + port: 8080 + health_endpoint: /health + + sogorch: + binary: /opt/sogoms/bin/sogorch + instances: 2 + socket_pattern: /run/sogorch.{id}.sock + health_endpoint: /health + + sogoms-db: + binary: /opt/sogoms/bin/sogoms-db + instances: 2 + socket_pattern: /run/sogoms-db.{id}.sock + env: + MARIA_HOST: 13.23.33.4 + MARIA_PORT: "3306" + MARIA_USER: sogoms + MARIA_PASS_FILE: /secrets/maria_password + + sogoms-pdf: + binary: /opt/sogoms/bin/sogoms-pdf + instances: 1 + socket_pattern: /run/sogoms-pdf.{id}.sock + autoscale: + enabled: true + min: 1 + max: 5 + metric: queue_depth + threshold: 10 + + sogoms-email: + binary: /opt/sogoms/bin/sogoms-email + instances: 1 + socket_pattern: /run/sogoms-email.{id}.sock + env: + SMTP_HOST: smtp.example.com + SMTP_PORT: "587" + + sogoms-storage: + binary: /opt/sogoms/bin/sogoms-storage + instances: 1 + socket_pattern: /run/sogoms-storage.{id}.sock + env: + STORAGE_BASE: /data/storage +``` + +#### CLI Sogoctl + +```bash +# Statut global +$ sogoctl status + +SERVICE INSTANCE PID STATUS CPU MEM UPTIME +sogoway 1 1234 running 2% 45MB 2h15m +sogorch 1 1235 running 5% 32MB 2h15m +sogorch 2 1236 running 3% 30MB 2h15m +sogoms-db 1 1237 running 1% 28MB 2h15m +sogoms-db 2 1238 running 1% 27MB 2h15m +sogoms-pdf 1 1239 running 0% 52MB 2h15m +sogoms-email 1 1240 running 0% 25MB 2h15m + +# Scaler un service +$ sogoctl scale sogoms-pdf 3 +Scaling sogoms-pdf: 1 โ†’ 3 +Starting sogoms-pdf.2 ... OK (PID 1241) +Starting sogoms-pdf.3 ... OK (PID 1242) + +# Instances d'un service +$ sogoctl instances sogoms-pdf + +INSTANCE PID SOCKET STATUS REQUESTS +1 1239 /run/sogoms-pdf.1.sock running 1,234 +2 1241 /run/sogoms-pdf.2.sock running 156 +3 1242 /run/sogoms-pdf.3.sock running 89 + +# Redรฉmarrer un service +$ sogoctl restart sogorch + +# Logs +$ sogoctl logs sogoms-pdf.1 --follow +$ sogoctl logs sogorch --all --since 1h + +# Stats temps rรฉel +$ sogoctl top + +# Info d'un service +$ sogoctl info sogorch +``` + +### 3.3 Sogoway (Gateway HTTP) + +Point d'entrรฉe HTTP unique pour toutes les requรชtes API. + +#### Responsabilitรฉs + +- Rรฉception des requรชtes HTTP +- Authentification (JWT, API Keys) +- Identification du tenant +- Routing vers les scรฉnarios +- Rate limiting +- Logging des requรชtes + +#### Configuration + +```yaml +# /config/sogoway.yaml + +server: + port: 8080 + read_timeout: 30s + write_timeout: 30s + max_body_size: 10MB + +auth: + jwt: + secret_env: JWT_SECRET + algorithm: HS256 + expiration: 24h + claims: + tenant_id: tenant + user_id: sub + roles: roles + api_keys: + enabled: true + header: X-API-Key + storage: redis + +tenant: + resolution: + - type: jwt_claim + claim: tenant_id + - type: header + name: X-Tenant-ID + - type: subdomain + pattern: "{tenant}.api.sogoms.com" + +rate_limit: + enabled: true + storage: redis + default: + requests: 1000 + window: 1m + by_tenant: true + +orchestrator: + pool_size: 10 + timeout: 30s + +routes_path: /config/routes + +logging: + level: info + format: json + output: stdout + +metrics: + enabled: true + path: /metrics +``` + +#### Routes par application + +```yaml +# /config/routes/prokov.yaml + +app: prokov +version: "1.0" +base_path: /api/prokov + +auth: + required: true + roles: [user, admin] + +routes: + # Liste des tรขches + - path: /tasks + method: GET + scenario: list_tasks + cache: + enabled: true + ttl: 30s + + # Crรฉer une tรขche + - path: /tasks + method: POST + scenario: create_task + rate_limit: + requests: 100 + window: 1m + + # Dรฉtail d'une tรขche + - path: /tasks/{id} + method: GET + scenario: get_task + + # Modifier une tรขche + - path: /tasks/{id} + method: PUT + scenario: update_task + + # Supprimer une tรขche + - path: /tasks/{id} + method: DELETE + scenario: delete_task + auth: + roles: [admin] +``` + +### 3.4 Sogorch (Orchestrateur) + +Exรฉcute les scรฉnarios YAML en appelant les microservices. + +#### Responsabilitรฉs + +- Charger et parser les scรฉnarios YAML +- Exรฉcuter les steps sรฉquentiellement ou en parallรจle +- Gรฉrer le contexte et les variables +- Appeler les Sogoms via Unix sockets +- Gรฉrer les erreurs, retry, conditions +- Retourner le rรฉsultat agrรฉgรฉ + +#### Scรฉnario type + +```yaml +# /config/scenarios/create_task.yaml + +name: create_task +version: "1.0" +description: Crรฉer une tรขche et notifier l'assignรฉ + +input: + required: + - title + - assigned_to + optional: + - description + - priority + - notify + defaults: + priority: medium + notify: false + +context: + created_at: "{{now}}" + created_by: "{{auth.user_id}}" + +steps: + # Valider que l'utilisateur assignรฉ existe + - id: validate_user + service: db + action: query_one + params: + query: "SELECT id, email FROM users WHERE id = ? AND tenant_id = ?" + args: ["{{input.assigned_to}}", "{{tenant.id}}"] + on_error: abort + error_message: "Utilisateur assignรฉ non trouvรฉ" + + # Insรฉrer la tรขche + - id: insert_task + service: db + action: insert + params: + table: tasks + data: + tenant_id: "{{tenant.id}}" + title: "{{input.title}}" + description: "{{input.description}}" + priority: "{{input.priority}}" + assigned_to: "{{input.assigned_to}}" + status: "pending" + created_by: "{{context.created_by}}" + created_at: "{{context.created_at}}" + on_error: abort + + # Notifier par email (optionnel, async) + - id: send_notification + service: email + action: send + condition: "{{input.notify == true}}" + async: true + params: + to: "{{steps.validate_user.result.email}}" + template: task_assigned + data: + task_id: "{{steps.insert_task.insert_id}}" + task_title: "{{input.title}}" + assigned_by: "{{auth.user_name}}" + +output: + status: 201 + body: + success: true + task: + id: "{{steps.insert_task.insert_id}}" + title: "{{input.title}}" + status: "pending" + created_at: "{{context.created_at}}" +``` + +#### Syntaxe des scรฉnarios + +```yaml +# Conditions +- id: premium_only + service: billing + action: check_feature + condition: "{{tenant.plan == 'premium'}}" + +# Boucles +- id: notify_all + service: email + action: send + foreach: "{{input.recipients}}" + foreach_as: recipient + params: + to: "{{recipient.email}}" + template: notification + +# Parallรฉlisme +- id: parallel_tasks + parallel: + - id: gen_pdf + service: pdf + action: generate + params: {...} + - id: send_copy + service: email + action: send + params: {...} + +# Retry +- id: external_call + service: http + action: post + retry: + attempts: 3 + delay: 2s + backoff: exponential + +# Switch/case +- id: route_by_type + switch: "{{input.document_type}}" + cases: + invoice: + service: pdf + action: generate_invoice + contract: + service: pdf + action: generate_contract + default: + service: pdf + action: generate_generic +``` + +### 3.5 Microservices (Sogoms-*) + +Chaque microservice est un binaire Go spรฉcialisรฉ. + +#### Structure commune + +```go +// Chaque Sogoms implรฉmente cette interface +type Service interface { + Name() string + Actions() map[string]ActionHandler + Health() error +} + +type ActionHandler func(ctx context.Context, params map[string]any) (any, error) +``` + +#### Sogoms-db + +```yaml +# Actions disponibles +actions: + - query # SELECT avec plusieurs rรฉsultats + - query_one # SELECT avec un seul rรฉsultat + - insert # INSERT, retourne insert_id + - update # UPDATE, retourne affected_rows + - delete # DELETE, retourne affected_rows + - exec # Requรชte brute + - transaction # Groupe de requรชtes transactionnelles +``` + +#### Sogoms-pdf + +```yaml +actions: + - generate # Gรฉnรฉrer un PDF depuis un template + - merge # Fusionner plusieurs PDFs + - split # Sรฉparer un PDF + - extract_text # Extraire le texte + - add_signature # Ajouter une signature +``` + +#### Sogoms-email + +```yaml +actions: + - send # Envoyer un email + - send_bulk # Envoyer en masse + - validate # Valider une adresse email +``` + +#### Sogoms-storage + +```yaml +actions: + - put # Stocker un fichier + - get # Rรฉcupรฉrer un fichier + - delete # Supprimer un fichier + - list # Lister les fichiers + - get_url # Obtenir une URL signรฉe temporaire +``` + +--- + +## 4. Communication Inter-Processus + +### 4.1 Protocole Unix Socket + +Les binaires communiquent via Unix sockets avec un protocole simple basรฉ sur JSON. + +#### Format des messages + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ 4 bytes โ”‚ N bytes โ”‚ +โ”‚ Length โ”‚ JSON Payload โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +#### Requรชte + +```json +{ + "id": "req_abc123", + "action": "insert", + "tenant_id": "acme", + "params": { + "table": "tasks", + "data": { + "title": "Ma tรขche", + "status": "pending" + } + }, + "timeout_ms": 5000 +} +``` + +#### Rรฉponse + +```json +{ + "id": "req_abc123", + "status": "success", + "result": { + "insert_id": 42, + "affected_rows": 1 + } +} +``` + +```json +{ + "id": "req_abc123", + "status": "error", + "error": { + "code": "DB_ERROR", + "message": "Duplicate entry for key 'title'" + } +} +``` + +### 4.2 Load Balancing + +Sogoctl maintient un pool de connexions pour chaque service : + +```go +type ServicePool struct { + name string + instances []*Instance + mu sync.RWMutex + counter uint64 +} + +func (p *ServicePool) Next() *Instance { + p.mu.RLock() + defer p.mu.RUnlock() + + // Round-robin + idx := atomic.AddUint64(&p.counter, 1) % uint64(len(p.instances)) + return p.instances[idx] +} +``` + +--- + +## 5. Multi-Tenancy + +### 5.1 Identification du tenant + +Le tenant est identifiรฉ dans cet ordre de prioritรฉ : + +1. Claim JWT `tenant_id` +2. Header `X-Tenant-ID` +3. Sous-domaine (`acme.api.sogoms.com`) +4. API Key (lookup en base) + +### 5.2 Configuration par tenant + +```yaml +# /config/tenants/acme.yaml + +tenant: + id: acme + name: ACME Corporation + domain: acme.example.com + plan: premium + status: active + +database: + prefix: acme_ # Tables prรฉfixรฉes ou schema sรฉparรฉ + +limits: + rate_limit: 5000/min + storage: 50GB + users: 100 + api_keys: 20 + +features: + - pdf + - email + - storage + - custom_workflows + +storage: + type: local # local | s3 | minio + config: + base_path: /data/storage/tenants/acme + +customization: + branding: + logo_url: https://acme.example.com/logo.png + primary_color: "#1a73e8" + email: + from_name: ACME Platform + from_email: noreply@acme.example.com +``` + +### 5.3 Isolation des donnรฉes + +```sql +-- Chaque table inclut tenant_id +CREATE TABLE tasks ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id VARCHAR(50) NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + status ENUM('pending', 'in_progress', 'completed') DEFAULT 'pending', + assigned_to BIGINT, + created_by BIGINT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_tenant (tenant_id), + INDEX idx_tenant_status (tenant_id, status) +); + +-- Sogoms-db ajoute automatiquement la clause tenant_id +``` + +--- + +## 6. Stockage Fichiers + +### 6.1 Structure + +``` +/data/storage/ +โ”œโ”€โ”€ _system/ # Fichiers systรจme SOGOMS +โ”‚ โ”œโ”€โ”€ templates/ # Templates PDF, email +โ”‚ โ”‚ โ”œโ”€โ”€ pdf/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ invoice.html +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ contract.html +โ”‚ โ”‚ โ””โ”€โ”€ email/ +โ”‚ โ”‚ โ”œโ”€โ”€ welcome.html +โ”‚ โ”‚ โ””โ”€โ”€ task_assigned.html +โ”‚ โ””โ”€โ”€ tmp/ # Fichiers temporaires +โ”‚ +โ””โ”€โ”€ tenants/ + โ”œโ”€โ”€ acme/ + โ”‚ โ”œโ”€โ”€ documents/ # PDFs gรฉnรฉrรฉs + โ”‚ โ”œโ”€โ”€ uploads/ # Fichiers uploadรฉs + โ”‚ โ””โ”€โ”€ tmp/ # Temporaires du tenant + โ”‚ + โ””โ”€โ”€ startup/ + โ”œโ”€โ”€ documents/ + โ”œโ”€โ”€ uploads/ + โ””โ”€โ”€ tmp/ +``` + +### 6.2 Interface Storage (รฉvolutive) + +```go +type Storage interface { + Put(ctx context.Context, path string, data []byte) error + Get(ctx context.Context, path string) ([]byte, error) + Delete(ctx context.Context, path string) error + List(ctx context.Context, prefix string) ([]string, error) + GetURL(ctx context.Context, path string, expiry time.Duration) (string, error) +} + +// Implรฉmentations disponibles +// - LocalStorage : fichiers locaux +// - S3Storage : AWS S3 / Scaleway Object Storage +// - MinioStorage : MinIO self-hosted +``` + +--- + +## 7. Sรฉcuritรฉ + +### 7.1 Authentification JWT + +```yaml +# Structure du token JWT +header: + alg: HS256 + typ: JWT + +payload: + sub: "user_123" # User ID + tenant: "acme" # Tenant ID + roles: ["user", "admin"] # Rรดles + exp: 1704067200 # Expiration + iat: 1703980800 # Issued at +``` + +### 7.2 Rate Limiting + +```yaml +# Par dรฉfaut +rate_limit: + default: 1000/min + +# Par tenant (override) +rate_limit: + acme: 5000/min + startup: 500/min + +# Par endpoint (override) +routes: + - path: /api/heavy-operation + rate_limit: + requests: 10 + window: 1m +``` + +### 7.3 Validation des entrรฉes + +Chaque scรฉnario valide ses inputs : + +```yaml +input: + required: + - email + - password + optional: + - name + validation: + email: + type: string + format: email + password: + type: string + min_length: 8 +``` + +--- + +## 8. Structure du Projet + +``` +sogoms/ +โ”œโ”€โ”€ cmd/ +โ”‚ โ”œโ”€โ”€ sogoctl/ # Superviseur +โ”‚ โ”‚ โ””โ”€โ”€ main.go +โ”‚ โ”œโ”€โ”€ sogoway/ # Gateway HTTP +โ”‚ โ”‚ โ””โ”€โ”€ main.go +โ”‚ โ”œโ”€โ”€ sogorch/ # Orchestrateur +โ”‚ โ”‚ โ””โ”€โ”€ main.go +โ”‚ โ””โ”€โ”€ sogoms/ +โ”‚ โ”œโ”€โ”€ db/ # Microservice DB +โ”‚ โ”‚ โ””โ”€โ”€ main.go +โ”‚ โ”œโ”€โ”€ pdf/ # Microservice PDF +โ”‚ โ”‚ โ””โ”€โ”€ main.go +โ”‚ โ”œโ”€โ”€ email/ # Microservice Email +โ”‚ โ”‚ โ””โ”€โ”€ main.go +โ”‚ โ””โ”€โ”€ storage/ # Microservice Storage +โ”‚ โ””โ”€โ”€ main.go +โ”‚ +โ”œโ”€โ”€ internal/ +โ”‚ โ”œโ”€โ”€ protocol/ # Protocole Unix socket +โ”‚ โ”‚ โ”œโ”€โ”€ message.go +โ”‚ โ”‚ โ”œโ”€โ”€ client.go +โ”‚ โ”‚ โ””โ”€โ”€ server.go +โ”‚ โ”œโ”€โ”€ pool/ # Load balancer interne +โ”‚ โ”‚ โ””โ”€โ”€ pool.go +โ”‚ โ”œโ”€โ”€ config/ # Lecture configs YAML +โ”‚ โ”‚ โ”œโ”€โ”€ config.go +โ”‚ โ”‚ โ”œโ”€โ”€ tenant.go +โ”‚ โ”‚ โ””โ”€โ”€ routes.go +โ”‚ โ”œโ”€โ”€ scenario/ # Exรฉcution scรฉnarios +โ”‚ โ”‚ โ”œโ”€โ”€ parser.go +โ”‚ โ”‚ โ”œโ”€โ”€ executor.go +โ”‚ โ”‚ โ””โ”€โ”€ context.go +โ”‚ โ”œโ”€โ”€ auth/ # JWT, API Keys +โ”‚ โ”‚ โ”œโ”€โ”€ jwt.go +โ”‚ โ”‚ โ””โ”€โ”€ apikey.go +โ”‚ โ””โ”€โ”€ registry/ # Registry des instances +โ”‚ โ””โ”€โ”€ registry.go +โ”‚ +โ”œโ”€โ”€ pkg/ +โ”‚ โ””โ”€โ”€ logger/ # Logger partagรฉ +โ”‚ โ””โ”€โ”€ logger.go +โ”‚ +โ”œโ”€โ”€ config/ +โ”‚ โ”œโ”€โ”€ sogoctl.yaml # Config superviseur +โ”‚ โ”œโ”€โ”€ sogoway.yaml # Config gateway +โ”‚ โ”œโ”€โ”€ tenants/ # Configs tenants +โ”‚ โ”‚ โ”œโ”€โ”€ acme.yaml +โ”‚ โ”‚ โ””โ”€โ”€ startup.yaml +โ”‚ โ”œโ”€โ”€ routes/ # Routes par application +โ”‚ โ”‚ โ””โ”€โ”€ prokov.yaml +โ”‚ โ””โ”€โ”€ scenarios/ # Scรฉnarios +โ”‚ โ”œโ”€โ”€ create_task.yaml +โ”‚ โ”œโ”€โ”€ update_task.yaml +โ”‚ โ””โ”€โ”€ delete_task.yaml +โ”‚ +โ”œโ”€โ”€ scripts/ +โ”‚ โ”œโ”€โ”€ build.sh # Build tous les binaires +โ”‚ โ”œโ”€โ”€ install.sh # Installation dans container +โ”‚ โ””โ”€โ”€ dev.sh # Lancement dev local +โ”‚ +โ”œโ”€โ”€ deploy/ +โ”‚ โ””โ”€โ”€ incus/ +โ”‚ โ”œโ”€โ”€ setup-gw3.sh # Setup container gw3 +โ”‚ โ””โ”€โ”€ setup-redis3.sh # Setup container redis3 +โ”‚ +โ”œโ”€โ”€ go.mod +โ”œโ”€โ”€ go.sum +โ”œโ”€โ”€ Makefile +โ””โ”€โ”€ README.md +``` + +--- + +## 9. Dรฉploiement + +### 9.1 Build + +```makefile +# Makefile + +BINARIES = sogoctl sogoway sogorch sogoms-db sogoms-pdf sogoms-email sogoms-storage +VERSION = $(shell git describe --tags --always) +LDFLAGS = -s -w -X main.version=$(VERSION) + +.PHONY: all clean $(BINARIES) + +all: $(BINARIES) + +sogoctl: + CGO_ENABLED=0 GOOS=linux go build -ldflags="$(LDFLAGS)" -o dist/sogoctl ./cmd/sogoctl + +sogoway: + CGO_ENABLED=0 GOOS=linux go build -ldflags="$(LDFLAGS)" -o dist/sogoway ./cmd/sogoway + +sogorch: + CGO_ENABLED=0 GOOS=linux go build -ldflags="$(LDFLAGS)" -o dist/sogorch ./cmd/sogorch + +sogoms-%: + CGO_ENABLED=0 GOOS=linux go build -ldflags="$(LDFLAGS)" -o dist/sogoms-$* ./cmd/sogoms/$* + +clean: + rm -rf dist/* + +install: all + scp dist/* gw3:/opt/sogoms/bin/ + ssh gw3 'sogoctl restart all' +``` + +### 9.2 Setup Container Incus + +```bash +#!/bin/bash +# deploy/incus/setup-gw3.sh + +# Crรฉer le container +incus launch images:alpine/3.21 gw3 + +# Configurer l'IP fixe +incus config device override gw3 eth0 ipv4.address=13.23.33.5 + +# Crรฉer les dossiers +incus exec gw3 -- mkdir -p /opt/sogoms/bin +incus exec gw3 -- mkdir -p /config +incus exec gw3 -- mkdir -p /data/storage/tenants +incus exec gw3 -- mkdir -p /run +incus exec gw3 -- mkdir -p /var/log/sogoms + +# Installer les dรฉpendances (pour sogoms-pdf notamment) +incus exec gw3 -- apk add --no-cache \ + chromium \ + font-noto \ + font-noto-emoji + +# Copier les binaires +incus file push dist/* gw3/opt/sogoms/bin/ + +# Copier les configs +incus file push -r config/* gw3/config/ + +# Configurer sogoctl comme service init +incus exec gw3 -- sh -c 'cat > /etc/init.d/sogoms << EOF +#!/sbin/openrc-run +command=/opt/sogoms/bin/sogoctl +command_args="--config /config/sogoctl.yaml" +pidfile=/run/sogoctl.pid +EOF' + +incus exec gw3 -- chmod +x /etc/init.d/sogoms +incus exec gw3 -- rc-update add sogoms default + +# Dรฉmarrer +incus exec gw3 -- rc-service sogoms start +``` + +### 9.3 Config Nginx Host + +```nginx +# /etc/nginx/sites-available/sogoms + +upstream sogoms_gateway { + server 13.23.33.5:8080; + keepalive 16; +} + +server { + listen 443 ssl http2; + server_name api.sogoms.com api-dev.sogoms.com; + + ssl_certificate /etc/letsencrypt/live/sogoms.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/sogoms.com/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + + access_log /var/log/nginx/sogoms.access.log; + error_log /var/log/nginx/sogoms.error.log; + + location / { + proxy_pass http://sogoms_gateway; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + + proxy_connect_timeout 10s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + location /ws/ { + proxy_pass http://sogoms_gateway; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 86400; + } +} +``` + +--- + +## 10. Monitoring + +### 10.1 Mรฉtriques (via Sogoctl) + +```bash +# API Admin :9000 + +GET /metrics # Prometheus format +GET /health # Health check global +GET /status # Status dรฉtaillรฉ JSON +GET /services # Liste des services et instances +GET /services/{name} # Dรฉtail d'un service +``` + +### 10.2 Logs + +```bash +# Logs centralisรฉs par Sogoctl +/var/log/sogoms/ +โ”œโ”€โ”€ sogoctl.log +โ”œโ”€โ”€ sogoway.log +โ”œโ”€โ”€ sogorch.log +โ”œโ”€โ”€ sogoms-db.log +โ”œโ”€โ”€ sogoms-pdf.log +โ”œโ”€โ”€ sogoms-email.log +โ””โ”€โ”€ sogoms-storage.log +``` + +### 10.3 SOGOMS Vigil + +Pour un monitoring avancรฉ multi-hosts avec agents, voir **[sogoms-vigil.md](sogoms-vigil.md)**. + +--- + +## 11. Roadmap + +### Phase 1 : Foundation (MVP) + +- [ ] Sogoctl : superviseur basique (start/stop/restart) +- [ ] Sogoway : routing, auth JWT +- [ ] Sogorch : exรฉcution scรฉnarios simples +- [ ] Sogoms-db : CRUD MariaDB +- [ ] Protocole Unix socket +- [ ] Config YAML (routes + scรฉnarios) + +### Phase 2 : Features + +- [ ] Multi-tenant complet +- [ ] Sogoms-pdf : gรฉnรฉration PDF +- [ ] Sogoms-email : envoi emails +- [ ] Sogoms-storage : gestion fichiers +- [ ] Rate limiting Redis +- [ ] Sogoctl : scaling dynamique + +### Phase 3 : Production + +- [ ] Health checks avancรฉs +- [ ] Mรฉtriques Prometheus +- [ ] Autoscaling basรฉ sur mรฉtriques +- [ ] Hot reload des configs +- [ ] Dashboard admin + +### Phase 4 : Enterprise + +- [ ] Workflows complexes (conditions, boucles, parallรฉlisme) +- [ ] API Keys management +- [ ] Webhooks +- [ ] Multi-rรฉgion +- [ ] Audit logs + +--- + +## 12. Rรฉfรฉrences + +- [Go Chi Router](https://github.com/go-chi/chi) - HTTP routing +- [Go YAML v3](https://github.com/go-yaml/yaml) - Parsing YAML +- [Go MariaDB Driver](https://github.com/go-sql-driver/mysql) - Accรจs MariaDB +- [Chromedp](https://github.com/chromedp/chromedp) - Gรฉnรฉration PDF +- [Incus Documentation](https://linuxcontainers.org/incus/docs/main/) - Containers + +--- + +**Website** : https://sogoms.com +**Documentation** : https://docs.sogoms.com +**API** : https://api.sogoms.com + +*Document mis ร  jour le 2025-01-15 - Version 2.0.0*