Étiquette : Coding

  • Socle V004 – Standard TechDB

    Socle V004 – Standard TechDB

    28 – Standard de Creation de Table H2

    Version : 4.0.1 Date : 2026-01-13 Package : eu.lmvi.socle.techdb

    Introduction

    Ce document definit le standard de creation des tables dans la base technique H2 du Socle V004. Ce standard garantit la coherence, la tracabilite et la compatibilite PostgreSQL/H2.

    Structure de Base

    Toutes les tables TechDB doivent suivre cette structure:

    Champ Type Description
    x_id BIGINT GENERATED BY DEFAULT AS IDENTITY Identifiant technique auto-genere
    x_dateCreated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP Date creation
    x_dateChanged TIMESTAMP WITH TIME ZONE Date modification (NULL a l’insert, MAJ par appli)
    x_sub VARCHAR(255) Sujet/categorie
    x_partition VARCHAR(30) Partition logique
    x_comment CLOB Commentaires/historiques JSON texte
    [champs metier] Champs specifiques a la table
    datas CLOB Donnees metier JSON texte (toujours en fin)

    Regles

    Identite

    • Utiliser GENERATED BY DEFAULT AS IDENTITY, pas AUTO_INCREMENT (MySQL)
    • Pas de sequence explicite
    • La colonne x_id est toujours la cle primaire
    -- Correct (SQL standard / H2 / PostgreSQL)
    x_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY
    
    -- Incorrect (MySQL uniquement)
    id BIGINT AUTO_INCREMENT PRIMARY KEY
    

    Timestamps

    • x_dateCreated: Toujours NOT NULL DEFAULT CURRENT_TIMESTAMP
    • x_dateChanged: NULL a l’insertion, mis a jour par l’application
    • Utiliser TIMESTAMP WITH TIME ZONE pour la compatibilite
    x_dateCreated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
    x_dateChanged TIMESTAMP WITH TIME ZONE,
    

    Triggers

    • Aucun trigger dans H2
    • L’audit, l’historisation et la mise a jour de x_dateChanged sont geres par l’application

    Cle Primaire

    • Si une cle existante est presente (ex: worker_name), la conserver comme UNIQUE
    • x_id reste la PK technique
    x_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    worker_name VARCHAR(255) NOT NULL UNIQUE,
    

    Conventions de Nommage

    Element Convention Exemple
    Champs techniques Prefixe x_ x_id, x_dateCreated
    Cles etrangeres id_<table_cible> id_user, id_order
    Champ id existant Renommer en x_id
    Donnees JSON datas en derniere position

    Permissions

    • Droits geres au niveau utilisateur H2
    • Proprietaire = utilisateur createur (socle par defaut)

    Contraintes H2 vs PostgreSQL

    PostgreSQL H2
    JSONB CLOB
    Triggers natifs Logique applicative
    Validation JSON DB Validation applicative
    SERIAL GENERATED BY DEFAULT AS IDENTITY
    Index GIN sur JSON Non supporte

    Exemple DDL Complet

    CREATE TABLE IF NOT EXISTS techdb_example (
        -- Champs techniques (standard)
        x_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
        x_dateCreated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
        x_dateChanged TIMESTAMP WITH TIME ZONE,
        x_sub VARCHAR(255),
        x_partition VARCHAR(30),
        x_comment CLOB,
    
        -- Champs metier specifiques
        example_key VARCHAR(255) NOT NULL UNIQUE,
        status VARCHAR(50) NOT NULL,
        counter INT DEFAULT 0,
        last_activity TIMESTAMP WITH TIME ZONE,
    
        -- Donnees JSON (toujours en dernier)
        datas CLOB
    );
    
    -- Index recommandes
    CREATE INDEX IF NOT EXISTS idx_example_key ON techdb_example(example_key);
    CREATE INDEX IF NOT EXISTS idx_example_status ON techdb_example(status);
    CREATE INDEX IF NOT EXISTS idx_example_created ON techdb_example(x_dateCreated);
    

    Tables TechDB du Socle

    Le Socle V004 definit 5 tables techniques:

    techdb_offsets

    Stockage des offsets de consommation (Kafka, NATS, etc.)

    CREATE TABLE IF NOT EXISTS techdb_offsets (
        x_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
        x_dateCreated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
        x_dateChanged TIMESTAMP WITH TIME ZONE,
        x_sub VARCHAR(255),
        x_partition VARCHAR(30),
        x_comment CLOB,
        offset_key VARCHAR(255) NOT NULL UNIQUE,
        topic VARCHAR(255) NOT NULL,
        partition_id INT DEFAULT 0,
        offset_value BIGINT NOT NULL,
        consumer_group VARCHAR(255),
        datas CLOB
    );
    

    techdb_worker_state

    Etat persistant des Workers

    CREATE TABLE IF NOT EXISTS techdb_worker_state (
        x_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
        x_dateCreated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
        x_dateChanged TIMESTAMP WITH TIME ZONE,
        x_sub VARCHAR(255),
        x_partition VARCHAR(30),
        x_comment CLOB,
        worker_name VARCHAR(255) NOT NULL UNIQUE,
        state VARCHAR(50) NOT NULL,
        last_run_at TIMESTAMP WITH TIME ZONE,
        next_run_at TIMESTAMP WITH TIME ZONE,
        error_count INT DEFAULT 0,
        last_error CLOB,
        datas CLOB
    );
    

    techdb_events

    Evenements techniques

    CREATE TABLE IF NOT EXISTS techdb_events (
        x_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
        x_dateCreated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
        x_dateChanged TIMESTAMP WITH TIME ZONE,
        x_sub VARCHAR(255),
        x_partition VARCHAR(30),
        x_comment CLOB,
        event_type VARCHAR(100) NOT NULL,
        source VARCHAR(255) NOT NULL,
        processed BOOLEAN DEFAULT FALSE,
        processed_at TIMESTAMP WITH TIME ZONE,
        datas CLOB
    );
    

    techdb_log_buffer

    Buffer de logs pour LogForwarder

    CREATE TABLE IF NOT EXISTS techdb_log_buffer (
        x_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
        x_dateCreated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
        x_dateChanged TIMESTAMP WITH TIME ZONE,
        x_sub VARCHAR(255),
        x_partition VARCHAR(30),
        x_comment CLOB,
        log_level VARCHAR(20) NOT NULL,
        logger_name VARCHAR(255),
        message CLOB NOT NULL,
        thread_name VARCHAR(255),
        log_timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
        forwarded BOOLEAN DEFAULT FALSE,
        forwarded_at TIMESTAMP WITH TIME ZONE,
        datas CLOB
    );
    

    techdb_kv

    Stockage cle-valeur generique

    CREATE TABLE IF NOT EXISTS techdb_kv (
        x_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
        x_dateCreated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
        x_dateChanged TIMESTAMP WITH TIME ZONE,
        x_sub VARCHAR(255),
        x_partition VARCHAR(30),
        x_comment CLOB,
        kv_key VARCHAR(512) NOT NULL UNIQUE,
        value_type VARCHAR(50) DEFAULT 'string',
        expires_at TIMESTAMP WITH TIME ZONE,
        datas CLOB
    );
    

    Bonnes Pratiques

    DO

    • Toujours utiliser le format standard x_ pour les champs techniques
    • Mettre datas en derniere colonne
    • Creer des index sur les colonnes frequemment requetees
    • Utiliser TIMESTAMP WITH TIME ZONE pour tous les timestamps
    • Documenter le contenu JSON attendu dans datas

    DON’T

    • Ne pas utiliser AUTO_INCREMENT (syntaxe MySQL)
    • Ne pas creer de triggers (gestion applicative)
    • Ne pas stocker de BLOBs volumineux (utiliser stockage externe)
    • Ne pas utiliser JSONB (non supporte par H2)
    • Ne pas omettre les index sur les colonnes de recherche

    Migration depuis l’ancien format

    Si vous avez des tables existantes avec l’ancien format:

    -- 1. Sauvegarder les donnees
    CREATE TABLE techdb_events_backup AS SELECT * FROM techdb_events;
    
    -- 2. Supprimer l'ancienne table
    DROP TABLE techdb_events;
    
    -- 3. Recreer avec le nouveau format
    -- (voir DDL ci-dessus)
    
    -- 4. Migrer les donnees
    INSERT INTO techdb_events (event_type, source, processed, processed_at, datas)
    SELECT event_type, source, processed, processed_at, payload
    FROM techdb_events_backup;
    
    -- 5. Supprimer la sauvegarde
    DROP TABLE techdb_events_backup;
    

    Voir aussi

    H2 = dev/tests/outillage – Structure compatible PostgreSQL/H2

    Socle V004 – Standard TechDB

  • Socle V004 – Démarrage Rapide

    Socle V004 – Démarrage Rapide

    03 – Guide de Démarrage Rapide

    Version : 4.0.0 Date : 2025-01-25 Temps estimé : 5 minutes

    1. Prérequis

    • JDK 21+ installé
    • Maven 3.9+ installé
    • Git (optionnel)

    Vérification :

    java -version   # java version "21.x.x"
    mvn -version    # Maven 3.9.x
    

    2. Installation

    Option A : Cloner le projet

    git clone <repo>/socle-v004.git
    cd socle-v004
    

    Option B : Créer un nouveau projet

    mvn archetype:generate \
      -DgroupId=com.mycompany \
      -DartifactId=my-socle-app \
      -DarchetypeArtifactId=maven-archetype-quickstart \
      -DinteractiveMode=false
    
    cd my-socle-app
    # Ajouter la dépendance socle-v004 dans pom.xml
    

    3. Configuration minimale

    3.1 pom.xml

    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
             http://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
    
        <groupId>eu.lmvi</groupId>
        <artifactId>socle-v004</artifactId>
        <version>4.0.0</version>
        <packaging>jar</packaging>
    
        <parent>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-parent</artifactId>
            <version>3.2.1</version>
        </parent>
    
        <properties>
            <java.version>21</java.version>
        </properties>
    
        <dependencies>
            <!-- Spring Boot Web (sans Logback) -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
                <exclusions>
                    <exclusion>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-logging</artifactId>
                    </exclusion>
                </exclusions>
            </dependency>
    
            <!-- Log4j2 -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-log4j2</artifactId>
            </dependency>
    
            <!-- LMAX Disruptor (AsyncLoggers) -->
            <dependency>
                <groupId>com.lmax</groupId>
                <artifactId>disruptor</artifactId>
                <version>4.0.0</version>
            </dependency>
    
            <!-- H2 Database -->
            <dependency>
                <groupId>com.h2database</groupId>
                <artifactId>h2</artifactId>
                <version>2.2.224</version>
            </dependency>
        </dependencies>
    
        <build>
            <plugins>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                </plugin>
            </plugins>
        </build>
    </project>
    

    3.2 application.yml

    spring:
      application:
        name: my-socle-app
    
    socle:
      app_name: ${APP_NAME:my-socle-app}
      version: 4.0.0
      env_name: ${ENV_NAME:DEV}
      region: ${REGION:local}
    
      # HTTP
      http_port: ${HTTP_PORT:8080}
    
      # KV Bus
      kv_impl: in_memory
    
      # Supervisor
      supervisor_enabled: true
    
      # H2 TechDB
      techdb:
        enabled: true
        url: jdbc:h2:file:./data/techdb;MODE=PostgreSQL
        console:
          enabled: true
          path: /h2-console
    
    logging:
      config: classpath:log4j2.xml
    

    3.3 log4j2.xml

    <?xml version="1.0" encoding="UTF-8"?>
    <Configuration status="WARN">
        <Appenders>
            <Console name="Console" target="SYSTEM_OUT">
                <PatternLayout pattern="%d{ISO8601} %-5level [%thread] %logger{36} - %msg%n"/>
            </Console>
        </Appenders>
        <Loggers>
            <Root level="INFO">
                <AppenderRef ref="Console"/>
            </Root>
        </Loggers>
    </Configuration>
    

    4. Créer un Worker simple

    package com.mycompany.worker;
    
    import eu.lmvi.socle.worker.Worker;
    import eu.lmvi.socle.supervisor.Supervisor;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Component;
    
    import java.util.Map;
    
    @Component
    public class HelloWorker implements Worker {
    
        private static final Logger log = LoggerFactory.getLogger(HelloWorker.class);
    
        @Autowired
        private Supervisor supervisor;  // Injection du Supervisor
    
        private int counter = 0;
        private volatile boolean running = false;
    
        @Override
        public String getName() {
            return "hello-worker";
        }
    
        @Override
        public void initialize() {
            log.info("HelloWorker initializing...");
        }
    
        @Override
        public void start() {
            running = true;
            log.info("HelloWorker started!");
        }
    
        @Override
        public void doWork() {
            if (!running) return;  // Vérifier le flag d'arrêt
    
            try {
                counter++;
                log.info("Hello from worker! Count: {}", counter);
    
                // IMPORTANT: Envoyer un heartbeat avec métriques
                supervisor.heartbeat(getName(), getStats());
    
            } catch (Exception e) {
                log.error("Erreur dans doWork", e);
                // NE PAS propager l'exception
            }
        }
    
        @Override
        public void stop() {
            running = false;
            log.info("HelloWorker stopped. Final count: {}", counter);
        }
    
        @Override
        public boolean isHealthy() {
            return running;
        }
    
        @Override
        public Map<String, Object> getStats() {
            return Map.of(
                "counter", counter,
                "running", running
            );
        }
    
        @Override
        public long getCycleIntervalMs() {
            return 5000; // doWork() toutes les 5 secondes
        }
    }
    

    5. Build et Run

    # Build
    mvn clean package -DskipTests
    
    # Run
    java -jar target/socle-v004-4.0.0.jar
    
    # Ou avec variables d'environnement
    APP_NAME=my-app ENV_NAME=PROD java -jar target/socle-v004-4.0.0.jar
    

    6. Vérification

    Health check

    curl http://localhost:8080/health
    

    Réponse attendue :

    {
      "status": "UP",
      "app": "my-socle-app",
      "version": "4.0.0"
    }
    

    H2 Console (dev)

    Ouvrir : http://localhost:8080/h2-console

    • JDBC URL : jdbc:h2:file:./data/techdb
    • User : socle
    • Password : socle

    Logs

    Vous devriez voir :

    INFO  [main] MainOrchestratorProcess - MOP démarré
    INFO  [main] HelloWorker - HelloWorker started!
    INFO  [scheduler-1] HelloWorker - Hello from worker! Count: 1
    INFO  [scheduler-1] HelloWorker - Hello from worker! Count: 2
    

    StatusDashboard (supervision)

    Ouvrir : http://localhost:9374/

    Ce dashboard affiche en temps réel :

    • L’état de chaque worker (RUNNING, DEGRADED, UNHEALTHY)
    • Les métriques transmises par les heartbeats
    • L’historique des changements d’état

    7. Prochaines étapes

    Document Description
    04-CONFIGURATION Configuration complète
    05-WORKERS Guide des Workers
    08-SUPERVISOR Supervision et heartbeats
    21-H2-TECHDB Base H2 TechDB
    22-LOG4J2-LOGFORWARDER Logging centralisé
    27-STATUS-DASHBOARD Dashboard de supervision
    GUIDE-METHODOLOGIQUE Bonnes pratiques

    8. Troubleshooting

    Port déjà utilisé

    # Changer le port
    HTTP_PORT=9090 java -jar target/socle-v004-4.0.0.jar
    

    H2 Console inaccessible

    Vérifier que socle.techdb.console.enabled: true dans application.yml.

    Logs non visibles

    Vérifier que log4j2.xml existe dans src/main/resources/.

  • Socle V004 – Worker Registry

    Socle V004 – Worker Registry

    24 – Client Worker Registry (Nouveauté V4)

    Version : 4.0.0 Date : 2025-12-09

    1. Introduction

    Le WorkerRegistryClient permet aux applications Socle V4 de s’auto-enregistrer auprès d’un Registry central pour la supervision.

    Bénéfices

    • Visibilité : Savoir quels workers sont actifs par région
    • Supervision : Détecter les workers « LOST » (heartbeat manquant)
    • Diagnostique : Informations version, capabilities, charge

    2. Architecture

    ┌─────────────────┐                    ┌─────────────────┐
    │   Application   │                    │  Registry       │
    │   Socle V4      │                    │  (central)      │
    └────────┬────────┘                    └────────┬────────┘
             │                                      │
             │  1. POST /workers/register           │
             │     (au démarrage)                   │
             │─────────────────────────────────────►│
             │                                      │
             │  2. POST /workers/heartbeat          │
             │     (toutes les 30s)                 │
             │─────────────────────────────────────►│
             │                                      │
             │  3. DELETE /workers/{id}             │
             │     (à l'arrêt)                      │
             │─────────────────────────────────────►│
             │                                      │
    
                        ┌─────────────────┐
                        │  Metabase /     │
                        │  Grafana        │◄────── Consultation
                        └─────────────────┘
    

    3. Configuration

    3.1 application.yml

    socle:
      worker-registry:
        enabled: ${WORKER_REGISTRY_ENABLED:false}
        server-url: ${WORKER_REGISTRY_URL:https://registry.lmvi.org}
        heartbeat-interval-ms: ${REGISTRY_HEARTBEAT_INTERVAL_MS:30000}
        connect-timeout-ms: 10000
        read-timeout-ms: 30000
    

    3.2 Variables d’environnement

    Variable Description Défaut
    WORKER_REGISTRY_ENABLED Activer le registry false
    WORKER_REGISTRY_URL URL du Registry
    REGISTRY_HEARTBEAT_INTERVAL_MS Intervalle heartbeat (ms) 30000

    4. Interface WorkerRegistryClient

    package eu.lmvi.socle.client.registry;
    
    /**
     * Client Registry pour auto-enregistrement des Workers
     */
    public interface WorkerRegistryClient {
    
        /**
         * Enregistrement initial au démarrage
         * @param registration Informations du worker
         * @throws RegistryException si échec
         */
        void register(WorkerRegistration registration) throws RegistryException;
    
        /**
         * Heartbeat périodique
         * @param heartbeat État courant du worker
         * @throws RegistryException si échec
         */
        void heartbeat(WorkerHeartbeat heartbeat) throws RegistryException;
    
        /**
         * Désenregistrement à l'arrêt
         * @param workerId ID du worker
         * @throws RegistryException si échec
         */
        void unregister(String workerId) throws RegistryException;
    
        /**
         * Vérifie si le worker est enregistré
         */
        boolean isRegistered();
    }
    

    5. DTOs

    5.1 WorkerRegistration

    package eu.lmvi.socle.client.registry;
    
    /**
     * Informations d'enregistrement d'un worker
     */
    public record WorkerRegistration(
        String workerId,          // Identifiant unique (ex: "AGENT-DB2-MTQ-001")
        String workerType,        // Type de worker (ex: "journal-reader")
        String region,            // Région (ex: "MTQ", "GUA", "REU")
        String host,              // Hostname
        String version,           // Version de l'application
        List<String> capabilities,// Capacités (ex: ["db2-cdc", "nats-publisher"])
        Map<String, Object> extra // Métadonnées additionnelles
    ) {
        public static WorkerRegistration of(SocleConfiguration config) {
            return new WorkerRegistration(
                config.getExec_id(),
                config.getApp_name(),
                config.getRegion(),
                getHostname(),
                config.getVersion(),
                List.of(),
                Map.of()
            );
        }
    
        private static String getHostname() {
            try {
                return InetAddress.getLocalHost().getHostName();
            } catch (UnknownHostException e) {
                return "unknown";
            }
        }
    }
    

    5.2 WorkerHeartbeat

    package eu.lmvi.socle.client.registry;
    
    /**
     * Heartbeat périodique d'un worker
     */
    public record WorkerHeartbeat(
        String workerId,           // ID du worker
        String status,             // RUNNING, STOPPING, ERROR
        Map<String, Object> load   // Métriques de charge
    ) {
        public static WorkerHeartbeat running(String workerId, Map<String, Object> load) {
            return new WorkerHeartbeat(workerId, "RUNNING", load);
        }
    
        public static WorkerHeartbeat stopping(String workerId) {
            return new WorkerHeartbeat(workerId, "STOPPING", Map.of());
        }
    
        public static WorkerHeartbeat error(String workerId, String errorMessage) {
            return new WorkerHeartbeat(workerId, "ERROR", Map.of("error", errorMessage));
        }
    }
    

    6. Implémentation

    package eu.lmvi.socle.client.registry;
    
    @Component
    @ConditionalOnProperty(name = "socle.worker-registry.enabled", havingValue = "true")
    public class HttpWorkerRegistryClient implements WorkerRegistryClient {
    
        private static final Logger log = LoggerFactory.getLogger(HttpWorkerRegistryClient.class);
    
        private final SocleConfiguration config;
        private final SocleAuthClient authClient;
        private final OkHttpClient httpClient;
        private final ObjectMapper objectMapper;
    
        private volatile boolean registered = false;
        private volatile String currentWorkerId;
    
        public HttpWorkerRegistryClient(
                SocleConfiguration config,
                @Autowired(required = false) SocleAuthClient authClient) {
            this.config = config;
            this.authClient = authClient;
            this.objectMapper = new ObjectMapper();
    
            this.httpClient = new OkHttpClient.Builder()
                .connectTimeout(config.getRegistryConnectTimeoutMs(), TimeUnit.MILLISECONDS)
                .readTimeout(config.getRegistryReadTimeoutMs(), TimeUnit.MILLISECONDS)
                .build();
        }
    
        @Override
        public void register(WorkerRegistration registration) throws RegistryException {
            log.info("Registering worker: {} ({})", registration.workerId(), registration.workerType());
    
            try {
                String json = objectMapper.writeValueAsString(registration);
    
                Request.Builder requestBuilder = new Request.Builder()
                    .url(config.getRegistryServerUrl() + "/api/v1/workers/register")
                    .post(RequestBody.create(json, MediaType.parse("application/json")));
    
                // Add auth if available
                if (authClient != null && authClient.isAuthenticated()) {
                    requestBuilder.header("Authorization", "Bearer " + authClient.getValidAccessToken());
                }
    
                try (Response response = httpClient.newCall(requestBuilder.build()).execute()) {
                    if (!response.isSuccessful()) {
                        throw new RegistryException("Registration failed: " + response.code());
                    }
    
                    registered = true;
                    currentWorkerId = registration.workerId();
                    log.info("Worker registered successfully: {}", registration.workerId());
                }
            } catch (IOException e) {
                throw new RegistryException("Registration failed", e);
            }
        }
    
        @Override
        public void heartbeat(WorkerHeartbeat heartbeat) throws RegistryException {
            if (!registered) {
                log.warn("Cannot send heartbeat, worker not registered");
                return;
            }
    
            log.debug("Sending heartbeat: {} - {}", heartbeat.workerId(), heartbeat.status());
    
            try {
                String json = objectMapper.writeValueAsString(heartbeat);
    
                Request.Builder requestBuilder = new Request.Builder()
                    .url(config.getRegistryServerUrl() + "/api/v1/workers/heartbeat")
                    .post(RequestBody.create(json, MediaType.parse("application/json")));
    
                if (authClient != null && authClient.isAuthenticated()) {
                    requestBuilder.header("Authorization", "Bearer " + authClient.getValidAccessToken());
                }
    
                try (Response response = httpClient.newCall(requestBuilder.build()).execute()) {
                    if (!response.isSuccessful()) {
                        log.warn("Heartbeat failed: {}", response.code());
                        // Don't throw - heartbeat failure is not critical
                    }
                }
            } catch (IOException e) {
                log.warn("Heartbeat failed: {}", e.getMessage());
                // Don't throw - heartbeat failure is not critical
            }
        }
    
        @Override
        public void unregister(String workerId) throws RegistryException {
            if (!registered) {
                return;
            }
    
            log.info("Unregistering worker: {}", workerId);
    
            try {
                Request.Builder requestBuilder = new Request.Builder()
                    .url(config.getRegistryServerUrl() + "/api/v1/workers/" + workerId)
                    .delete();
    
                if (authClient != null && authClient.isAuthenticated()) {
                    requestBuilder.header("Authorization", "Bearer " + authClient.getValidAccessToken());
                }
    
                try (Response response = httpClient.newCall(requestBuilder.build()).execute()) {
                    // Ignore response - best effort
                    registered = false;
                    currentWorkerId = null;
                    log.info("Worker unregistered: {}", workerId);
                }
            } catch (IOException e) {
                log.warn("Unregister failed: {}", e.getMessage());
                // Don't throw - unregister failure is not critical
            }
        }
    
        @Override
        public boolean isRegistered() {
            return registered;
        }
    }
    

    7. Intégration avec MOP

    7.1 Enregistrement au démarrage

    // Dans MainOrchestratorProcess.start()
    if (registryClient != null && config.isWorkerRegistryEnabled()) {
        log.info("[step:registry_register] Enregistrement au Worker Registry");
        try {
            WorkerRegistration registration = new WorkerRegistration(
                config.getExec_id(),
                config.getApp_name(),
                config.getRegion(),
                InetAddress.getLocalHost().getHostName(),
                config.getVersion(),
                getWorkerCapabilities(),
                Map.of(
                    "startTime", Instant.now(),
                    "javaVersion", System.getProperty("java.version")
                )
            );
            registryClient.register(registration);
        } catch (RegistryException e) {
            log.warn("Registry registration failed, continuing", e);
        }
    }
    

    7.2 Heartbeat périodique

    // Dans la boucle principale ou via ScheduledExecutorService
    private void sendRegistryHeartbeat() {
        if (registryClient == null || !registryClient.isRegistered()) {
            return;
        }
    
        try {
            WorkerHeartbeat heartbeat = new WorkerHeartbeat(
                config.getExec_id(),
                "RUNNING",
                Map.of(
                    "uptime", getUptime(),
                    "workersCount", workers.size(),
                    "healthyWorkers", countHealthyWorkers(),
                    "memoryUsedMb", getMemoryUsedMb()
                )
            );
            registryClient.heartbeat(heartbeat);
        } catch (RegistryException e) {
            log.debug("Heartbeat failed: {}", e.getMessage());
        }
    }
    

    7.3 Désenregistrement à l’arrêt

    // Dans MainOrchestratorProcess.gracefulShutdown()
    if (registryClient != null && registryClient.isRegistered()) {
        log.info("[step:registry_unregister] Désenregistrement du Registry");
        try {
            registryClient.unregister(config.getExec_id());
        } catch (RegistryException e) {
            log.warn("Unregister failed", e);
        }
    }
    

    8. Exemple de données

    8.1 Registration

    {
      "workerId": "AGENT-DB2-MTQ-001",
      "workerType": "journal-reader",
      "region": "MTQ",
      "host": "mtq-nuc-01",
      "version": "4.0.0",
      "capabilities": ["db2-cdc", "nats-publisher"],
      "extra": {
        "journal": "QUSR0023",
        "library": "IMA001FDMQ",
        "startTime": "2025-12-09T10:00:00Z",
        "javaVersion": "21.0.1"
      }
    }
    

    8.2 Heartbeat

    {
      "workerId": "AGENT-DB2-MTQ-001",
      "status": "RUNNING",
      "load": {
        "uptime": 3600,
        "messagesPerMinute": 523,
        "lastSequence": 123456789,
        "memoryUsedMb": 256,
        "cpuPercent": 15
      }
    }
    

    9. Côté serveur (Registry central)

    9.1 Table worker_registry

    CREATE TABLE worker_registry (
        id BIGSERIAL PRIMARY KEY,
        worker_id VARCHAR(200) NOT NULL UNIQUE,
        worker_type VARCHAR(100) NOT NULL,
        region VARCHAR(50),
        host VARCHAR(200),
        version VARCHAR(50),
        status VARCHAR(20) DEFAULT 'UNKNOWN',
        registered_at TIMESTAMPTZ DEFAULT NOW(),
        last_heartbeat TIMESTAMPTZ,
        capabilities JSONB,
        extra JSONB,
        load JSONB
    );
    
    CREATE INDEX idx_worker_registry_region ON worker_registry(region);
    CREATE INDEX idx_worker_registry_type ON worker_registry(worker_type);
    CREATE INDEX idx_worker_registry_status ON worker_registry(status);
    

    9.2 Détection des workers LOST

    -- Workers sans heartbeat depuis plus de 2 minutes
    UPDATE worker_registry
    SET status = 'LOST'
    WHERE status = 'RUNNING'
      AND last_heartbeat < NOW() - INTERVAL '2 minutes';
    

    9.3 Dashboard Metabase/Grafana

    -- Workers actifs par région
    SELECT region, COUNT(*) as count
    FROM worker_registry
    WHERE status = 'RUNNING'
    GROUP BY region;
    
    -- Workers LOST
    SELECT worker_id, region, last_heartbeat
    FROM worker_registry
    WHERE status = 'LOST';
    

    10. Bonnes pratiques

    DO

    • ✅ Enregistrer au démarrage, désenregistrer à l’arrêt
    • ✅ Heartbeat régulier (30s recommandé)
    • ✅ Inclure des métriques utiles dans le heartbeat
    • ✅ Gérer gracieusement les échecs (non bloquant)

    DON’T

    • ❌ Heartbeat trop fréquent (< 10s)
    • ❌ Bloquer sur les erreurs registry
    • ❌ Stocker des données sensibles dans extra

    11. Troubleshooting

    Worker non visible dans le dashboard

    1. Vérifier WORKER_REGISTRY_ENABLED=true
    2. Vérifier WORKER_REGISTRY_URL
    3. Vérifier les logs : « Worker registered successfully »

    Worker marqué LOST

    1. Vérifier que l’application tourne
    2. Vérifier la connectivité réseau
    3. Vérifier les logs heartbeat

    Erreur 401

    1. Vérifier que AUTH_ENABLED=true
    2. Vérifier API_KEY
    3. Vérifier que l’auth fonctionne

    12. Références

  • Socle V004 – Sécurité

    Socle V004 – Sécurité

    10 – Security

    Version : 4.0.0 Date : 2025-12-09

    1. Introduction

    Le Socle V4 intègre plusieurs mécanismes de sécurité :

    • Authentification Admin API (Basic Auth)
    • Client JWT pour services externes (nouveauté V4)
    • Filtrage des endpoints sensibles
    • Gestion sécurisée des secrets

    2. Authentification Admin API

    2.1 Configuration

    socle:
      admin:
        enabled: true
        auth:
          enabled: ${ADMIN_AUTH_ENABLED:false}
          username: ${ADMIN_USERNAME:admin}
          password: ${ADMIN_PASSWORD:}
    

    2.2 AdminAuthFilter

    package eu.lmvi.socle.security;
    
    @Component
    @Order(1)
    public class AdminAuthFilter implements Filter {
    
        private final SocleConfiguration config;
    
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
                throws IOException, ServletException {
    
            HttpServletRequest httpRequest = (HttpServletRequest) request;
            HttpServletResponse httpResponse = (HttpServletResponse) response;
    
            // Skip si auth désactivée
            if (!config.getAdmin().getAuth().isEnabled()) {
                chain.doFilter(request, response);
                return;
            }
    
            // Skip les endpoints publics
            String path = httpRequest.getRequestURI();
            if (isPublicEndpoint(path)) {
                chain.doFilter(request, response);
                return;
            }
    
            // Vérifier l'authentification pour /admin/*
            if (path.startsWith("/admin")) {
                String authHeader = httpRequest.getHeader("Authorization");
    
                if (!isValidAuth(authHeader)) {
                    httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                    httpResponse.setHeader("WWW-Authenticate", "Basic realm=\"Admin API\"");
                    return;
                }
            }
    
            chain.doFilter(request, response);
        }
    
        private boolean isPublicEndpoint(String path) {
            return path.equals("/admin/health") || path.equals("/admin/health/live");
        }
    
        private boolean isValidAuth(String authHeader) {
            if (authHeader == null || !authHeader.startsWith("Basic ")) {
                return false;
            }
    
            String base64Credentials = authHeader.substring("Basic ".length());
            String credentials = new String(Base64.getDecoder().decode(base64Credentials));
            String[] parts = credentials.split(":", 2);
    
            if (parts.length != 2) {
                return false;
            }
    
            return parts[0].equals(config.getAdmin().getAuth().getUsername())
                && parts[1].equals(config.getAdmin().getAuth().getPassword());
        }
    }
    

    2.3 Utilisation

    # Sans auth
    curl http://localhost:8080/admin/health
    
    # Avec auth
    curl -u admin:secret http://localhost:8080/admin/workers
    

    3. JWT Auth Client (Nouveauté V4)

    3.1 Principe

    Le SocleAuthClient permet aux applications Socle de s’authentifier auprès de services centraux (LogHub, Registry, etc.).

    Application Socle  ──────►  Auth Server  ──────►  Services sécurisés
          │                          │                      │
          │  1. Login (API Key)      │                      │
          │─────────────────────────►│                      │
          │                          │                      │
          │  2. JWT tokens           │                      │
          │◄─────────────────────────│                      │
          │                          │                      │
          │  3. Requêtes avec Bearer │                      │
          │──────────────────────────────────────────────────►
    

    3.2 Configuration

    socle:
      auth:
        enabled: ${AUTH_ENABLED:false}
        server-url: ${AUTH_SERVER_URL:https://auth.mycompany.com}
        source-name: ${SOURCE_NAME:${socle.app_name}}
        api-key: ${API_KEY:}
        access-token-buffer-seconds: 60
    

    3.3 Utilisation

    @Service
    public class SecuredApiClient {
    
        @Autowired(required = false)
        private SocleAuthClient authClient;
    
        public void callSecuredApi() {
            if (authClient == null || !authClient.isAuthenticated()) {
                throw new SecurityException("Authentication required");
            }
    
            String token = authClient.getValidAccessToken();
    
            HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create("https://api.mycompany.com/data"))
                .header("Authorization", "Bearer " + token)
                .build();
    
            // ...
        }
    }
    

    Voir 23-AUTH-CLIENT pour la documentation complète.

    4. Gestion des secrets

    4.1 Variables d’environnement

    # JAMAIS dans le code ou les fichiers committes
    export ADMIN_PASSWORD="super-secret-password"
    export API_KEY="my-api-key"
    export REDIS_PASSWORD="redis-password"
    export TECHDB_PASSWORD="techdb-password"
    

    4.2 Docker Secrets

    # docker-compose.yml
    services:
      app:
        image: socle-v4:latest
        secrets:
          - admin_password
          - api_key
        environment:
          - ADMIN_PASSWORD_FILE=/run/secrets/admin_password
          - API_KEY_FILE=/run/secrets/api_key
    
    secrets:
      admin_password:
        file: ./secrets/admin_password.txt
      api_key:
        file: ./secrets/api_key.txt
    

    4.3 Kubernetes Secrets

    apiVersion: v1
    kind: Secret
    metadata:
      name: socle-secrets
    type: Opaque
    stringData:
      ADMIN_PASSWORD: "super-secret"
      API_KEY: "my-api-key"
    ---
    apiVersion: apps/v1
    kind: Deployment
    spec:
      template:
        spec:
          containers:
            - name: app
              envFrom:
                - secretRef:
                    name: socle-secrets
    

    4.4 Configuration sécurisée

    @Configuration
    public class SecretsConfiguration {
    
        @Value("${ADMIN_PASSWORD:#{null}}")
        private String adminPassword;
    
        @Value("${ADMIN_PASSWORD_FILE:#{null}}")
        private String adminPasswordFile;
    
        @PostConstruct
        public void loadSecrets() {
            // Charger depuis fichier si spécifié
            if (adminPasswordFile != null) {
                try {
                    adminPassword = Files.readString(Path.of(adminPasswordFile)).trim();
                } catch (IOException e) {
                    throw new RuntimeException("Failed to load secret from file", e);
                }
            }
        }
    
        public String getAdminPassword() {
            return adminPassword;
        }
    }
    

    5. CORS Configuration

    @Configuration
    public class CorsConfiguration implements WebMvcConfigurer {
    
        @Value("${socle.security.cors.allowed-origins:*}")
        private String allowedOrigins;
    
        @Override
        public void addCorsMappings(CorsRegistry registry) {
            registry.addMapping("/api/**")
                .allowedOrigins(allowedOrigins.split(","))
                .allowedMethods("GET", "POST", "PUT", "DELETE")
                .allowedHeaders("*")
                .exposedHeaders("X-Total-Count")
                .allowCredentials(true)
                .maxAge(3600);
        }
    }
    

    6. Rate Limiting

    6.1 Implémentation simple

    @Component
    public class RateLimitFilter implements Filter {
    
        private final KvBus kvBus;
        private final int maxRequests = 100;
        private final Duration window = Duration.ofMinutes(1);
    
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
                throws IOException, ServletException {
    
            HttpServletRequest httpRequest = (HttpServletRequest) request;
            HttpServletResponse httpResponse = (HttpServletResponse) response;
    
            String clientId = getClientId(httpRequest);
            String key = "ratelimit:" + clientId + ":" + Instant.now().truncatedTo(ChronoUnit.MINUTES);
    
            long count = kvBus.increment(key);
            if (count == 1) {
                kvBus.setTtl(key, window);
            }
    
            if (count > maxRequests) {
                httpResponse.setStatus(429);
                httpResponse.setHeader("Retry-After", "60");
                httpResponse.getWriter().write("Rate limit exceeded");
                return;
            }
    
            httpResponse.setHeader("X-RateLimit-Limit", String.valueOf(maxRequests));
            httpResponse.setHeader("X-RateLimit-Remaining", String.valueOf(maxRequests - count));
    
            chain.doFilter(request, response);
        }
    
        private String getClientId(HttpServletRequest request) {
            String apiKey = request.getHeader("X-API-Key");
            if (apiKey != null) {
                return apiKey;
            }
            return request.getRemoteAddr();
        }
    }
    

    7. Input Validation

    7.1 Validation des DTOs

    public record CreateOrderRequest(
        @NotNull @Size(min = 1, max = 100)
        String customerId,
    
        @NotEmpty
        List<@Valid OrderItem> items,
    
        @Email
        String notificationEmail
    ) {}
    
    public record OrderItem(
        @NotNull @Size(min = 1, max = 50)
        String productId,
    
        @Min(1) @Max(1000)
        int quantity
    ) {}
    

    7.2 Controller avec validation

    @RestController
    @RequestMapping("/api/orders")
    public class OrderController {
    
        @PostMapping
        public ResponseEntity<Order> createOrder(@Valid @RequestBody CreateOrderRequest request) {
            // request est validé automatiquement
            return ResponseEntity.ok(orderService.create(request));
        }
    }
    

    7.3 Sanitization

    public class InputSanitizer {
    
        private static final Pattern SAFE_STRING = Pattern.compile("^[a-zA-Z0-9-_]+$");
    
        public static String sanitizeId(String input) {
            if (input == null) return null;
            if (!SAFE_STRING.matcher(input).matches()) {
                throw new IllegalArgumentException("Invalid ID format");
            }
            return input;
        }
    
        public static String sanitizeForLog(String input) {
            if (input == null) return null;
            return input.replaceAll("[\n\r\t]", "_");
        }
    }
    

    8. Logging sécurisé

    8.1 Ne pas logger les secrets

    // MAUVAIS
    log.info("Connecting with password: {}", password);
    log.info("API Key: {}", apiKey);
    log.info("Request: {}", requestWithSensitiveData);
    
    // BON
    log.info("Connecting to database");
    log.info("Using API Key: {}...", apiKey.substring(0, 4));
    log.info("Request received for user: {}", sanitizeForLog(userId));
    

    8.2 Pattern pour masquer les données sensibles

    public class SecureLogger {
    
        private static final Set<String> SENSITIVE_FIELDS = Set.of(
            "password", "apiKey", "token", "secret", "credential"
        );
    
        public static String maskSensitiveData(Map<String, Object> data) {
            Map<String, Object> masked = new HashMap<>();
            for (Map.Entry<String, Object> entry : data.entrySet()) {
                if (SENSITIVE_FIELDS.contains(entry.getKey().toLowerCase())) {
                    masked.put(entry.getKey(), "***MASKED***");
                } else {
                    masked.put(entry.getKey(), entry.getValue());
                }
            }
            return masked.toString();
        }
    }
    

    9. Headers de sécurité

    @Component
    public class SecurityHeadersFilter implements Filter {
    
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
                throws IOException, ServletException {
    
            HttpServletResponse httpResponse = (HttpServletResponse) response;
    
            // Prevent clickjacking
            httpResponse.setHeader("X-Frame-Options", "DENY");
    
            // XSS protection
            httpResponse.setHeader("X-Content-Type-Options", "nosniff");
            httpResponse.setHeader("X-XSS-Protection", "1; mode=block");
    
            // HSTS (si HTTPS)
            httpResponse.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
    
            // Content Security Policy
            httpResponse.setHeader("Content-Security-Policy", "default-src 'self'");
    
            chain.doFilter(request, response);
        }
    }
    

    10. Audit logging

    @Aspect
    @Component
    public class AuditAspect {
    
        private static final Logger auditLog = LoggerFactory.getLogger("AUDIT");
    
        @Around("@annotation(Audited)")
        public Object audit(ProceedingJoinPoint joinPoint) throws Throwable {
            String method = joinPoint.getSignature().getName();
            String user = getCurrentUser();
            Instant start = Instant.now();
    
            try {
                Object result = joinPoint.proceed();
                auditLog.info("SUCCESS | user={} | method={} | duration={}ms",
                    user, method, Duration.between(start, Instant.now()).toMillis());
                return result;
            } catch (Exception e) {
                auditLog.warn("FAILURE | user={} | method={} | error={}",
                    user, method, e.getMessage());
                throw e;
            }
        }
    
        private String getCurrentUser() {
            // Récupérer l'utilisateur du contexte
            return "system";
        }
    }
    
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface Audited {}
    

    11. Checklist de sécurité

    Configuration

    • [ ] ADMIN_AUTH_ENABLED=true en production
    • [ ] Mots de passe forts et uniques
    • [ ] Secrets via variables d’environnement ou secret manager
    • [ ] HTTPS activé
    • [ ] CORS configuré correctement

    Code

    • [ ] Validation de tous les inputs
    • [ ] Pas de secrets dans les logs
    • [ ] Headers de sécurité activés
    • [ ] Rate limiting en place
    • [ ] Audit logging activé

    Infrastructure

    • [ ] Firewall configuré
    • [ ] Ports non nécessaires fermés
    • [ ] H2 Console désactivée en production
    • [ ] Accès admin restreint

    12. Références

  • Socle V004 – Worker Registry

    Socle V004 – Worker Registry

    24 – Client Worker Registry (Nouveauté V4)

    Version : 4.0.0 Date : 2025-12-09

    1. Introduction

    Le WorkerRegistryClient permet aux applications Socle V4 de s’auto-enregistrer auprès d’un Registry central pour la supervision.

    Bénéfices

    • Visibilité : Savoir quels workers sont actifs par région
    • Supervision : Détecter les workers « LOST » (heartbeat manquant)
    • Diagnostique : Informations version, capabilities, charge

    2. Architecture

    ┌─────────────────┐                    ┌─────────────────┐
    │   Application   │                    │  Registry       │
    │   Socle V4      │                    │  (central)      │
    └────────┬────────┘                    └────────┬────────┘
             │                                      │
             │  1. POST /workers/register           │
             │     (au démarrage)                   │
             │─────────────────────────────────────►│
             │                                      │
             │  2. POST /workers/heartbeat          │
             │     (toutes les 30s)                 │
             │─────────────────────────────────────►│
             │                                      │
             │  3. DELETE /workers/{id}             │
             │     (à l'arrêt)                      │
             │─────────────────────────────────────►│
             │                                      │
    
                        ┌─────────────────┐
                        │  Metabase /     │
                        │  Grafana        │◄────── Consultation
                        └─────────────────┘
    

    3. Configuration

    3.1 application.yml

    socle:
      worker-registry:
        enabled: ${WORKER_REGISTRY_ENABLED:false}
        server-url: ${WORKER_REGISTRY_URL:https://registry.lmvi.org}
        heartbeat-interval-ms: ${REGISTRY_HEARTBEAT_INTERVAL_MS:30000}
        connect-timeout-ms: 10000
        read-timeout-ms: 30000
    

    3.2 Variables d’environnement

    Variable Description Défaut
    WORKER_REGISTRY_ENABLED Activer le registry false
    WORKER_REGISTRY_URL URL du Registry
    REGISTRY_HEARTBEAT_INTERVAL_MS Intervalle heartbeat (ms) 30000

    4. Interface WorkerRegistryClient

    package eu.lmvi.socle.client.registry;
    
    /**
     * Client Registry pour auto-enregistrement des Workers
     */
    public interface WorkerRegistryClient {
    
        /**
         * Enregistrement initial au démarrage
         * @param registration Informations du worker
         * @throws RegistryException si échec
         */
        void register(WorkerRegistration registration) throws RegistryException;
    
        /**
         * Heartbeat périodique
         * @param heartbeat État courant du worker
         * @throws RegistryException si échec
         */
        void heartbeat(WorkerHeartbeat heartbeat) throws RegistryException;
    
        /**
         * Désenregistrement à l'arrêt
         * @param workerId ID du worker
         * @throws RegistryException si échec
         */
        void unregister(String workerId) throws RegistryException;
    
        /**
         * Vérifie si le worker est enregistré
         */
        boolean isRegistered();
    }
    

    5. DTOs

    5.1 WorkerRegistration

    package eu.lmvi.socle.client.registry;
    
    /**
     * Informations d'enregistrement d'un worker
     */
    public record WorkerRegistration(
        String workerId,          // Identifiant unique (ex: "AGENT-DB2-MTQ-001")
        String workerType,        // Type de worker (ex: "journal-reader")
        String region,            // Région (ex: "MTQ", "GUA", "REU")
        String host,              // Hostname
        String version,           // Version de l'application
        List<String> capabilities,// Capacités (ex: ["db2-cdc", "nats-publisher"])
        Map<String, Object> extra // Métadonnées additionnelles
    ) {
        public static WorkerRegistration of(SocleConfiguration config) {
            return new WorkerRegistration(
                config.getExec_id(),
                config.getApp_name(),
                config.getRegion(),
                getHostname(),
                config.getVersion(),
                List.of(),
                Map.of()
            );
        }
    
        private static String getHostname() {
            try {
                return InetAddress.getLocalHost().getHostName();
            } catch (UnknownHostException e) {
                return "unknown";
            }
        }
    }
    

    5.2 WorkerHeartbeat

    package eu.lmvi.socle.client.registry;
    
    /**
     * Heartbeat périodique d'un worker
     */
    public record WorkerHeartbeat(
        String workerId,           // ID du worker
        String status,             // RUNNING, STOPPING, ERROR
        Map<String, Object> load   // Métriques de charge
    ) {
        public static WorkerHeartbeat running(String workerId, Map<String, Object> load) {
            return new WorkerHeartbeat(workerId, "RUNNING", load);
        }
    
        public static WorkerHeartbeat stopping(String workerId) {
            return new WorkerHeartbeat(workerId, "STOPPING", Map.of());
        }
    
        public static WorkerHeartbeat error(String workerId, String errorMessage) {
            return new WorkerHeartbeat(workerId, "ERROR", Map.of("error", errorMessage));
        }
    }
    

    6. Implémentation

    package eu.lmvi.socle.client.registry;
    
    @Component
    @ConditionalOnProperty(name = "socle.worker-registry.enabled", havingValue = "true")
    public class HttpWorkerRegistryClient implements WorkerRegistryClient {
    
        private static final Logger log = LoggerFactory.getLogger(HttpWorkerRegistryClient.class);
    
        private final SocleConfiguration config;
        private final SocleAuthClient authClient;
        private final OkHttpClient httpClient;
        private final ObjectMapper objectMapper;
    
        private volatile boolean registered = false;
        private volatile String currentWorkerId;
    
        public HttpWorkerRegistryClient(
                SocleConfiguration config,
                @Autowired(required = false) SocleAuthClient authClient) {
            this.config = config;
            this.authClient = authClient;
            this.objectMapper = new ObjectMapper();
    
            this.httpClient = new OkHttpClient.Builder()
                .connectTimeout(config.getRegistryConnectTimeoutMs(), TimeUnit.MILLISECONDS)
                .readTimeout(config.getRegistryReadTimeoutMs(), TimeUnit.MILLISECONDS)
                .build();
        }
    
        @Override
        public void register(WorkerRegistration registration) throws RegistryException {
            log.info("Registering worker: {} ({})", registration.workerId(), registration.workerType());
    
            try {
                String json = objectMapper.writeValueAsString(registration);
    
                Request.Builder requestBuilder = new Request.Builder()
                    .url(config.getRegistryServerUrl() + "/api/v1/workers/register")
                    .post(RequestBody.create(json, MediaType.parse("application/json")));
    
                // Add auth if available
                if (authClient != null && authClient.isAuthenticated()) {
                    requestBuilder.header("Authorization", "Bearer " + authClient.getValidAccessToken());
                }
    
                try (Response response = httpClient.newCall(requestBuilder.build()).execute()) {
                    if (!response.isSuccessful()) {
                        throw new RegistryException("Registration failed: " + response.code());
                    }
    
                    registered = true;
                    currentWorkerId = registration.workerId();
                    log.info("Worker registered successfully: {}", registration.workerId());
                }
            } catch (IOException e) {
                throw new RegistryException("Registration failed", e);
            }
        }
    
        @Override
        public void heartbeat(WorkerHeartbeat heartbeat) throws RegistryException {
            if (!registered) {
                log.warn("Cannot send heartbeat, worker not registered");
                return;
            }
    
            log.debug("Sending heartbeat: {} - {}", heartbeat.workerId(), heartbeat.status());
    
            try {
                String json = objectMapper.writeValueAsString(heartbeat);
    
                Request.Builder requestBuilder = new Request.Builder()
                    .url(config.getRegistryServerUrl() + "/api/v1/workers/heartbeat")
                    .post(RequestBody.create(json, MediaType.parse("application/json")));
    
                if (authClient != null && authClient.isAuthenticated()) {
                    requestBuilder.header("Authorization", "Bearer " + authClient.getValidAccessToken());
                }
    
                try (Response response = httpClient.newCall(requestBuilder.build()).execute()) {
                    if (!response.isSuccessful()) {
                        log.warn("Heartbeat failed: {}", response.code());
                        // Don't throw - heartbeat failure is not critical
                    }
                }
            } catch (IOException e) {
                log.warn("Heartbeat failed: {}", e.getMessage());
                // Don't throw - heartbeat failure is not critical
            }
        }
    
        @Override
        public void unregister(String workerId) throws RegistryException {
            if (!registered) {
                return;
            }
    
            log.info("Unregistering worker: {}", workerId);
    
            try {
                Request.Builder requestBuilder = new Request.Builder()
                    .url(config.getRegistryServerUrl() + "/api/v1/workers/" + workerId)
                    .delete();
    
                if (authClient != null && authClient.isAuthenticated()) {
                    requestBuilder.header("Authorization", "Bearer " + authClient.getValidAccessToken());
                }
    
                try (Response response = httpClient.newCall(requestBuilder.build()).execute()) {
                    // Ignore response - best effort
                    registered = false;
                    currentWorkerId = null;
                    log.info("Worker unregistered: {}", workerId);
                }
            } catch (IOException e) {
                log.warn("Unregister failed: {}", e.getMessage());
                // Don't throw - unregister failure is not critical
            }
        }
    
        @Override
        public boolean isRegistered() {
            return registered;
        }
    }
    

    7. Intégration avec MOP

    7.1 Enregistrement au démarrage

    // Dans MainOrchestratorProcess.start()
    if (registryClient != null && config.isWorkerRegistryEnabled()) {
        log.info("[step:registry_register] Enregistrement au Worker Registry");
        try {
            WorkerRegistration registration = new WorkerRegistration(
                config.getExec_id(),
                config.getApp_name(),
                config.getRegion(),
                InetAddress.getLocalHost().getHostName(),
                config.getVersion(),
                getWorkerCapabilities(),
                Map.of(
                    "startTime", Instant.now(),
                    "javaVersion", System.getProperty("java.version")
                )
            );
            registryClient.register(registration);
        } catch (RegistryException e) {
            log.warn("Registry registration failed, continuing", e);
        }
    }
    

    7.2 Heartbeat périodique

    // Dans la boucle principale ou via ScheduledExecutorService
    private void sendRegistryHeartbeat() {
        if (registryClient == null || !registryClient.isRegistered()) {
            return;
        }
    
        try {
            WorkerHeartbeat heartbeat = new WorkerHeartbeat(
                config.getExec_id(),
                "RUNNING",
                Map.of(
                    "uptime", getUptime(),
                    "workersCount", workers.size(),
                    "healthyWorkers", countHealthyWorkers(),
                    "memoryUsedMb", getMemoryUsedMb()
                )
            );
            registryClient.heartbeat(heartbeat);
        } catch (RegistryException e) {
            log.debug("Heartbeat failed: {}", e.getMessage());
        }
    }
    

    7.3 Désenregistrement à l’arrêt

    // Dans MainOrchestratorProcess.gracefulShutdown()
    if (registryClient != null && registryClient.isRegistered()) {
        log.info("[step:registry_unregister] Désenregistrement du Registry");
        try {
            registryClient.unregister(config.getExec_id());
        } catch (RegistryException e) {
            log.warn("Unregister failed", e);
        }
    }
    

    8. Exemple de données

    8.1 Registration

    {
      "workerId": "AGENT-DB2-MTQ-001",
      "workerType": "journal-reader",
      "region": "MTQ",
      "host": "mtq-nuc-01",
      "version": "4.0.0",
      "capabilities": ["db2-cdc", "nats-publisher"],
      "extra": {
        "journal": "QUSR0023",
        "library": "IMA001FDMQ",
        "startTime": "2025-12-09T10:00:00Z",
        "javaVersion": "21.0.1"
      }
    }
    

    8.2 Heartbeat

    {
      "workerId": "AGENT-DB2-MTQ-001",
      "status": "RUNNING",
      "load": {
        "uptime": 3600,
        "messagesPerMinute": 523,
        "lastSequence": 123456789,
        "memoryUsedMb": 256,
        "cpuPercent": 15
      }
    }
    

    9. Côté serveur (Registry central)

    9.1 Table worker_registry

    CREATE TABLE worker_registry (
        id BIGSERIAL PRIMARY KEY,
        worker_id VARCHAR(200) NOT NULL UNIQUE,
        worker_type VARCHAR(100) NOT NULL,
        region VARCHAR(50),
        host VARCHAR(200),
        version VARCHAR(50),
        status VARCHAR(20) DEFAULT 'UNKNOWN',
        registered_at TIMESTAMPTZ DEFAULT NOW(),
        last_heartbeat TIMESTAMPTZ,
        capabilities JSONB,
        extra JSONB,
        load JSONB
    );
    
    CREATE INDEX idx_worker_registry_region ON worker_registry(region);
    CREATE INDEX idx_worker_registry_type ON worker_registry(worker_type);
    CREATE INDEX idx_worker_registry_status ON worker_registry(status);
    

    9.2 Détection des workers LOST

    -- Workers sans heartbeat depuis plus de 2 minutes
    UPDATE worker_registry
    SET status = 'LOST'
    WHERE status = 'RUNNING'
      AND last_heartbeat < NOW() - INTERVAL '2 minutes';
    

    9.3 Dashboard Metabase/Grafana

    -- Workers actifs par région
    SELECT region, COUNT(*) as count
    FROM worker_registry
    WHERE status = 'RUNNING'
    GROUP BY region;
    
    -- Workers LOST
    SELECT worker_id, region, last_heartbeat
    FROM worker_registry
    WHERE status = 'LOST';
    

    10. Bonnes pratiques

    DO

    • ✅ Enregistrer au démarrage, désenregistrer à l’arrêt
    • ✅ Heartbeat régulier (30s recommandé)
    • ✅ Inclure des métriques utiles dans le heartbeat
    • ✅ Gérer gracieusement les échecs (non bloquant)

    DON’T

    • ❌ Heartbeat trop fréquent (< 10s)
    • ❌ Bloquer sur les erreurs registry
    • ❌ Stocker des données sensibles dans extra

    11. Troubleshooting

    Worker non visible dans le dashboard

    1. Vérifier WORKER_REGISTRY_ENABLED=true
    2. Vérifier WORKER_REGISTRY_URL
    3. Vérifier les logs : « Worker registered successfully »

    Worker marqué LOST

    1. Vérifier que l’application tourne
    2. Vérifier la connectivité réseau
    3. Vérifier les logs heartbeat

    Erreur 401

    1. Vérifier que AUTH_ENABLED=true
    2. Vérifier API_KEY
    3. Vérifier que l’auth fonctionne

    12. Références

  • Socle V004 – Sécurité

    Socle V004 – Sécurité

    10 – Security

    Version : 4.0.0 Date : 2025-12-09

    1. Introduction

    Le Socle V4 intègre plusieurs mécanismes de sécurité :

    • Authentification Admin API (Basic Auth)
    • Client JWT pour services externes (nouveauté V4)
    • Filtrage des endpoints sensibles
    • Gestion sécurisée des secrets

    2. Authentification Admin API

    2.1 Configuration

    socle:
      admin:
        enabled: true
        auth:
          enabled: ${ADMIN_AUTH_ENABLED:false}
          username: ${ADMIN_USERNAME:admin}
          password: ${ADMIN_PASSWORD:}
    

    2.2 AdminAuthFilter

    package eu.lmvi.socle.security;
    
    @Component
    @Order(1)
    public class AdminAuthFilter implements Filter {
    
        private final SocleConfiguration config;
    
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
                throws IOException, ServletException {
    
            HttpServletRequest httpRequest = (HttpServletRequest) request;
            HttpServletResponse httpResponse = (HttpServletResponse) response;
    
            // Skip si auth désactivée
            if (!config.getAdmin().getAuth().isEnabled()) {
                chain.doFilter(request, response);
                return;
            }
    
            // Skip les endpoints publics
            String path = httpRequest.getRequestURI();
            if (isPublicEndpoint(path)) {
                chain.doFilter(request, response);
                return;
            }
    
            // Vérifier l'authentification pour /admin/*
            if (path.startsWith("/admin")) {
                String authHeader = httpRequest.getHeader("Authorization");
    
                if (!isValidAuth(authHeader)) {
                    httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                    httpResponse.setHeader("WWW-Authenticate", "Basic realm=\"Admin API\"");
                    return;
                }
            }
    
            chain.doFilter(request, response);
        }
    
        private boolean isPublicEndpoint(String path) {
            return path.equals("/admin/health") || path.equals("/admin/health/live");
        }
    
        private boolean isValidAuth(String authHeader) {
            if (authHeader == null || !authHeader.startsWith("Basic ")) {
                return false;
            }
    
            String base64Credentials = authHeader.substring("Basic ".length());
            String credentials = new String(Base64.getDecoder().decode(base64Credentials));
            String[] parts = credentials.split(":", 2);
    
            if (parts.length != 2) {
                return false;
            }
    
            return parts[0].equals(config.getAdmin().getAuth().getUsername())
                && parts[1].equals(config.getAdmin().getAuth().getPassword());
        }
    }
    

    2.3 Utilisation

    # Sans auth
    curl http://localhost:8080/admin/health
    
    # Avec auth
    curl -u admin:secret http://localhost:8080/admin/workers
    

    3. JWT Auth Client (Nouveauté V4)

    3.1 Principe

    Le SocleAuthClient permet aux applications Socle de s’authentifier auprès de services centraux (LogHub, Registry, etc.).

    Application Socle  ──────►  Auth Server  ──────►  Services sécurisés
          │                          │                      │
          │  1. Login (API Key)      │                      │
          │─────────────────────────►│                      │
          │                          │                      │
          │  2. JWT tokens           │                      │
          │◄─────────────────────────│                      │
          │                          │                      │
          │  3. Requêtes avec Bearer │                      │
          │──────────────────────────────────────────────────►
    

    3.2 Configuration

    socle:
      auth:
        enabled: ${AUTH_ENABLED:false}
        server-url: ${AUTH_SERVER_URL:https://auth.mycompany.com}
        source-name: ${SOURCE_NAME:${socle.app_name}}
        api-key: ${API_KEY:}
        access-token-buffer-seconds: 60
    

    3.3 Utilisation

    @Service
    public class SecuredApiClient {
    
        @Autowired(required = false)
        private SocleAuthClient authClient;
    
        public void callSecuredApi() {
            if (authClient == null || !authClient.isAuthenticated()) {
                throw new SecurityException("Authentication required");
            }
    
            String token = authClient.getValidAccessToken();
    
            HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create("https://api.mycompany.com/data"))
                .header("Authorization", "Bearer " + token)
                .build();
    
            // ...
        }
    }
    

    Voir 23-AUTH-CLIENT pour la documentation complète.

    4. Gestion des secrets

    4.1 Variables d’environnement

    # JAMAIS dans le code ou les fichiers committes
    export ADMIN_PASSWORD="super-secret-password"
    export API_KEY="my-api-key"
    export REDIS_PASSWORD="redis-password"
    export TECHDB_PASSWORD="techdb-password"
    

    4.2 Docker Secrets

    # docker-compose.yml
    services:
      app:
        image: socle-v4:latest
        secrets:
          - admin_password
          - api_key
        environment:
          - ADMIN_PASSWORD_FILE=/run/secrets/admin_password
          - API_KEY_FILE=/run/secrets/api_key
    
    secrets:
      admin_password:
        file: ./secrets/admin_password.txt
      api_key:
        file: ./secrets/api_key.txt
    

    4.3 Kubernetes Secrets

    apiVersion: v1
    kind: Secret
    metadata:
      name: socle-secrets
    type: Opaque
    stringData:
      ADMIN_PASSWORD: "super-secret"
      API_KEY: "my-api-key"
    ---
    apiVersion: apps/v1
    kind: Deployment
    spec:
      template:
        spec:
          containers:
            - name: app
              envFrom:
                - secretRef:
                    name: socle-secrets
    

    4.4 Configuration sécurisée

    @Configuration
    public class SecretsConfiguration {
    
        @Value("${ADMIN_PASSWORD:#{null}}")
        private String adminPassword;
    
        @Value("${ADMIN_PASSWORD_FILE:#{null}}")
        private String adminPasswordFile;
    
        @PostConstruct
        public void loadSecrets() {
            // Charger depuis fichier si spécifié
            if (adminPasswordFile != null) {
                try {
                    adminPassword = Files.readString(Path.of(adminPasswordFile)).trim();
                } catch (IOException e) {
                    throw new RuntimeException("Failed to load secret from file", e);
                }
            }
        }
    
        public String getAdminPassword() {
            return adminPassword;
        }
    }
    

    5. CORS Configuration

    @Configuration
    public class CorsConfiguration implements WebMvcConfigurer {
    
        @Value("${socle.security.cors.allowed-origins:*}")
        private String allowedOrigins;
    
        @Override
        public void addCorsMappings(CorsRegistry registry) {
            registry.addMapping("/api/**")
                .allowedOrigins(allowedOrigins.split(","))
                .allowedMethods("GET", "POST", "PUT", "DELETE")
                .allowedHeaders("*")
                .exposedHeaders("X-Total-Count")
                .allowCredentials(true)
                .maxAge(3600);
        }
    }
    

    6. Rate Limiting

    6.1 Implémentation simple

    @Component
    public class RateLimitFilter implements Filter {
    
        private final KvBus kvBus;
        private final int maxRequests = 100;
        private final Duration window = Duration.ofMinutes(1);
    
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
                throws IOException, ServletException {
    
            HttpServletRequest httpRequest = (HttpServletRequest) request;
            HttpServletResponse httpResponse = (HttpServletResponse) response;
    
            String clientId = getClientId(httpRequest);
            String key = "ratelimit:" + clientId + ":" + Instant.now().truncatedTo(ChronoUnit.MINUTES);
    
            long count = kvBus.increment(key);
            if (count == 1) {
                kvBus.setTtl(key, window);
            }
    
            if (count > maxRequests) {
                httpResponse.setStatus(429);
                httpResponse.setHeader("Retry-After", "60");
                httpResponse.getWriter().write("Rate limit exceeded");
                return;
            }
    
            httpResponse.setHeader("X-RateLimit-Limit", String.valueOf(maxRequests));
            httpResponse.setHeader("X-RateLimit-Remaining", String.valueOf(maxRequests - count));
    
            chain.doFilter(request, response);
        }
    
        private String getClientId(HttpServletRequest request) {
            String apiKey = request.getHeader("X-API-Key");
            if (apiKey != null) {
                return apiKey;
            }
            return request.getRemoteAddr();
        }
    }
    

    7. Input Validation

    7.1 Validation des DTOs

    public record CreateOrderRequest(
        @NotNull @Size(min = 1, max = 100)
        String customerId,
    
        @NotEmpty
        List<@Valid OrderItem> items,
    
        @Email
        String notificationEmail
    ) {}
    
    public record OrderItem(
        @NotNull @Size(min = 1, max = 50)
        String productId,
    
        @Min(1) @Max(1000)
        int quantity
    ) {}
    

    7.2 Controller avec validation

    @RestController
    @RequestMapping("/api/orders")
    public class OrderController {
    
        @PostMapping
        public ResponseEntity<Order> createOrder(@Valid @RequestBody CreateOrderRequest request) {
            // request est validé automatiquement
            return ResponseEntity.ok(orderService.create(request));
        }
    }
    

    7.3 Sanitization

    public class InputSanitizer {
    
        private static final Pattern SAFE_STRING = Pattern.compile("^[a-zA-Z0-9-_]+$");
    
        public static String sanitizeId(String input) {
            if (input == null) return null;
            if (!SAFE_STRING.matcher(input).matches()) {
                throw new IllegalArgumentException("Invalid ID format");
            }
            return input;
        }
    
        public static String sanitizeForLog(String input) {
            if (input == null) return null;
            return input.replaceAll("[\n\r\t]", "_");
        }
    }
    

    8. Logging sécurisé

    8.1 Ne pas logger les secrets

    // MAUVAIS
    log.info("Connecting with password: {}", password);
    log.info("API Key: {}", apiKey);
    log.info("Request: {}", requestWithSensitiveData);
    
    // BON
    log.info("Connecting to database");
    log.info("Using API Key: {}...", apiKey.substring(0, 4));
    log.info("Request received for user: {}", sanitizeForLog(userId));
    

    8.2 Pattern pour masquer les données sensibles

    public class SecureLogger {
    
        private static final Set<String> SENSITIVE_FIELDS = Set.of(
            "password", "apiKey", "token", "secret", "credential"
        );
    
        public static String maskSensitiveData(Map<String, Object> data) {
            Map<String, Object> masked = new HashMap<>();
            for (Map.Entry<String, Object> entry : data.entrySet()) {
                if (SENSITIVE_FIELDS.contains(entry.getKey().toLowerCase())) {
                    masked.put(entry.getKey(), "***MASKED***");
                } else {
                    masked.put(entry.getKey(), entry.getValue());
                }
            }
            return masked.toString();
        }
    }
    

    9. Headers de sécurité

    @Component
    public class SecurityHeadersFilter implements Filter {
    
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
                throws IOException, ServletException {
    
            HttpServletResponse httpResponse = (HttpServletResponse) response;
    
            // Prevent clickjacking
            httpResponse.setHeader("X-Frame-Options", "DENY");
    
            // XSS protection
            httpResponse.setHeader("X-Content-Type-Options", "nosniff");
            httpResponse.setHeader("X-XSS-Protection", "1; mode=block");
    
            // HSTS (si HTTPS)
            httpResponse.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
    
            // Content Security Policy
            httpResponse.setHeader("Content-Security-Policy", "default-src 'self'");
    
            chain.doFilter(request, response);
        }
    }
    

    10. Audit logging

    @Aspect
    @Component
    public class AuditAspect {
    
        private static final Logger auditLog = LoggerFactory.getLogger("AUDIT");
    
        @Around("@annotation(Audited)")
        public Object audit(ProceedingJoinPoint joinPoint) throws Throwable {
            String method = joinPoint.getSignature().getName();
            String user = getCurrentUser();
            Instant start = Instant.now();
    
            try {
                Object result = joinPoint.proceed();
                auditLog.info("SUCCESS | user={} | method={} | duration={}ms",
                    user, method, Duration.between(start, Instant.now()).toMillis());
                return result;
            } catch (Exception e) {
                auditLog.warn("FAILURE | user={} | method={} | error={}",
                    user, method, e.getMessage());
                throw e;
            }
        }
    
        private String getCurrentUser() {
            // Récupérer l'utilisateur du contexte
            return "system";
        }
    }
    
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface Audited {}
    

    11. Checklist de sécurité

    Configuration

    • [ ] ADMIN_AUTH_ENABLED=true en production
    • [ ] Mots de passe forts et uniques
    • [ ] Secrets via variables d’environnement ou secret manager
    • [ ] HTTPS activé
    • [ ] CORS configuré correctement

    Code

    • [ ] Validation de tous les inputs
    • [ ] Pas de secrets dans les logs
    • [ ] Headers de sécurité activés
    • [ ] Rate limiting en place
    • [ ] Audit logging activé

    Infrastructure

    • [ ] Firewall configuré
    • [ ] Ports non nécessaires fermés
    • [ ] H2 Console désactivée en production
    • [ ] Accès admin restreint

    12. Références

  • Socle V004 – Worker Registry

    Socle V004 – Worker Registry

    24 – Client Worker Registry (Nouveauté V4)

    Version : 4.0.0 Date : 2025-12-09

    1. Introduction

    Le WorkerRegistryClient permet aux applications Socle V4 de s’auto-enregistrer auprès d’un Registry central pour la supervision.

    Bénéfices

    • Visibilité : Savoir quels workers sont actifs par région
    • Supervision : Détecter les workers « LOST » (heartbeat manquant)
    • Diagnostique : Informations version, capabilities, charge

    2. Architecture

    ┌─────────────────┐                    ┌─────────────────┐
    │   Application   │                    │  Registry       │
    │   Socle V4      │                    │  (central)      │
    └────────┬────────┘                    └────────┬────────┘
             │                                      │
             │  1. POST /workers/register           │
             │     (au démarrage)                   │
             │─────────────────────────────────────►│
             │                                      │
             │  2. POST /workers/heartbeat          │
             │     (toutes les 30s)                 │
             │─────────────────────────────────────►│
             │                                      │
             │  3. DELETE /workers/{id}             │
             │     (à l'arrêt)                      │
             │─────────────────────────────────────►│
             │                                      │
    
                        ┌─────────────────┐
                        │  Metabase /     │
                        │  Grafana        │◄────── Consultation
                        └─────────────────┘
    

    3. Configuration

    3.1 application.yml

    socle:
      worker-registry:
        enabled: ${WORKER_REGISTRY_ENABLED:false}
        server-url: ${WORKER_REGISTRY_URL:https://registry.lmvi.org}
        heartbeat-interval-ms: ${REGISTRY_HEARTBEAT_INTERVAL_MS:30000}
        connect-timeout-ms: 10000
        read-timeout-ms: 30000
    

    3.2 Variables d’environnement

    Variable Description Défaut
    WORKER_REGISTRY_ENABLED Activer le registry false
    WORKER_REGISTRY_URL URL du Registry
    REGISTRY_HEARTBEAT_INTERVAL_MS Intervalle heartbeat (ms) 30000

    4. Interface WorkerRegistryClient

    package eu.lmvi.socle.client.registry;
    
    /**
     * Client Registry pour auto-enregistrement des Workers
     */
    public interface WorkerRegistryClient {
    
        /**
         * Enregistrement initial au démarrage
         * @param registration Informations du worker
         * @throws RegistryException si échec
         */
        void register(WorkerRegistration registration) throws RegistryException;
    
        /**
         * Heartbeat périodique
         * @param heartbeat État courant du worker
         * @throws RegistryException si échec
         */
        void heartbeat(WorkerHeartbeat heartbeat) throws RegistryException;
    
        /**
         * Désenregistrement à l'arrêt
         * @param workerId ID du worker
         * @throws RegistryException si échec
         */
        void unregister(String workerId) throws RegistryException;
    
        /**
         * Vérifie si le worker est enregistré
         */
        boolean isRegistered();
    }
    

    5. DTOs

    5.1 WorkerRegistration

    package eu.lmvi.socle.client.registry;
    
    /**
     * Informations d'enregistrement d'un worker
     */
    public record WorkerRegistration(
        String workerId,          // Identifiant unique (ex: "AGENT-DB2-MTQ-001")
        String workerType,        // Type de worker (ex: "journal-reader")
        String region,            // Région (ex: "MTQ", "GUA", "REU")
        String host,              // Hostname
        String version,           // Version de l'application
        List<String> capabilities,// Capacités (ex: ["db2-cdc", "nats-publisher"])
        Map<String, Object> extra // Métadonnées additionnelles
    ) {
        public static WorkerRegistration of(SocleConfiguration config) {
            return new WorkerRegistration(
                config.getExec_id(),
                config.getApp_name(),
                config.getRegion(),
                getHostname(),
                config.getVersion(),
                List.of(),
                Map.of()
            );
        }
    
        private static String getHostname() {
            try {
                return InetAddress.getLocalHost().getHostName();
            } catch (UnknownHostException e) {
                return "unknown";
            }
        }
    }
    

    5.2 WorkerHeartbeat

    package eu.lmvi.socle.client.registry;
    
    /**
     * Heartbeat périodique d'un worker
     */
    public record WorkerHeartbeat(
        String workerId,           // ID du worker
        String status,             // RUNNING, STOPPING, ERROR
        Map<String, Object> load   // Métriques de charge
    ) {
        public static WorkerHeartbeat running(String workerId, Map<String, Object> load) {
            return new WorkerHeartbeat(workerId, "RUNNING", load);
        }
    
        public static WorkerHeartbeat stopping(String workerId) {
            return new WorkerHeartbeat(workerId, "STOPPING", Map.of());
        }
    
        public static WorkerHeartbeat error(String workerId, String errorMessage) {
            return new WorkerHeartbeat(workerId, "ERROR", Map.of("error", errorMessage));
        }
    }
    

    6. Implémentation

    package eu.lmvi.socle.client.registry;
    
    @Component
    @ConditionalOnProperty(name = "socle.worker-registry.enabled", havingValue = "true")
    public class HttpWorkerRegistryClient implements WorkerRegistryClient {
    
        private static final Logger log = LoggerFactory.getLogger(HttpWorkerRegistryClient.class);
    
        private final SocleConfiguration config;
        private final SocleAuthClient authClient;
        private final OkHttpClient httpClient;
        private final ObjectMapper objectMapper;
    
        private volatile boolean registered = false;
        private volatile String currentWorkerId;
    
        public HttpWorkerRegistryClient(
                SocleConfiguration config,
                @Autowired(required = false) SocleAuthClient authClient) {
            this.config = config;
            this.authClient = authClient;
            this.objectMapper = new ObjectMapper();
    
            this.httpClient = new OkHttpClient.Builder()
                .connectTimeout(config.getRegistryConnectTimeoutMs(), TimeUnit.MILLISECONDS)
                .readTimeout(config.getRegistryReadTimeoutMs(), TimeUnit.MILLISECONDS)
                .build();
        }
    
        @Override
        public void register(WorkerRegistration registration) throws RegistryException {
            log.info("Registering worker: {} ({})", registration.workerId(), registration.workerType());
    
            try {
                String json = objectMapper.writeValueAsString(registration);
    
                Request.Builder requestBuilder = new Request.Builder()
                    .url(config.getRegistryServerUrl() + "/api/v1/workers/register")
                    .post(RequestBody.create(json, MediaType.parse("application/json")));
    
                // Add auth if available
                if (authClient != null && authClient.isAuthenticated()) {
                    requestBuilder.header("Authorization", "Bearer " + authClient.getValidAccessToken());
                }
    
                try (Response response = httpClient.newCall(requestBuilder.build()).execute()) {
                    if (!response.isSuccessful()) {
                        throw new RegistryException("Registration failed: " + response.code());
                    }
    
                    registered = true;
                    currentWorkerId = registration.workerId();
                    log.info("Worker registered successfully: {}", registration.workerId());
                }
            } catch (IOException e) {
                throw new RegistryException("Registration failed", e);
            }
        }
    
        @Override
        public void heartbeat(WorkerHeartbeat heartbeat) throws RegistryException {
            if (!registered) {
                log.warn("Cannot send heartbeat, worker not registered");
                return;
            }
    
            log.debug("Sending heartbeat: {} - {}", heartbeat.workerId(), heartbeat.status());
    
            try {
                String json = objectMapper.writeValueAsString(heartbeat);
    
                Request.Builder requestBuilder = new Request.Builder()
                    .url(config.getRegistryServerUrl() + "/api/v1/workers/heartbeat")
                    .post(RequestBody.create(json, MediaType.parse("application/json")));
    
                if (authClient != null && authClient.isAuthenticated()) {
                    requestBuilder.header("Authorization", "Bearer " + authClient.getValidAccessToken());
                }
    
                try (Response response = httpClient.newCall(requestBuilder.build()).execute()) {
                    if (!response.isSuccessful()) {
                        log.warn("Heartbeat failed: {}", response.code());
                        // Don't throw - heartbeat failure is not critical
                    }
                }
            } catch (IOException e) {
                log.warn("Heartbeat failed: {}", e.getMessage());
                // Don't throw - heartbeat failure is not critical
            }
        }
    
        @Override
        public void unregister(String workerId) throws RegistryException {
            if (!registered) {
                return;
            }
    
            log.info("Unregistering worker: {}", workerId);
    
            try {
                Request.Builder requestBuilder = new Request.Builder()
                    .url(config.getRegistryServerUrl() + "/api/v1/workers/" + workerId)
                    .delete();
    
                if (authClient != null && authClient.isAuthenticated()) {
                    requestBuilder.header("Authorization", "Bearer " + authClient.getValidAccessToken());
                }
    
                try (Response response = httpClient.newCall(requestBuilder.build()).execute()) {
                    // Ignore response - best effort
                    registered = false;
                    currentWorkerId = null;
                    log.info("Worker unregistered: {}", workerId);
                }
            } catch (IOException e) {
                log.warn("Unregister failed: {}", e.getMessage());
                // Don't throw - unregister failure is not critical
            }
        }
    
        @Override
        public boolean isRegistered() {
            return registered;
        }
    }
    

    7. Intégration avec MOP

    7.1 Enregistrement au démarrage

    // Dans MainOrchestratorProcess.start()
    if (registryClient != null && config.isWorkerRegistryEnabled()) {
        log.info("[step:registry_register] Enregistrement au Worker Registry");
        try {
            WorkerRegistration registration = new WorkerRegistration(
                config.getExec_id(),
                config.getApp_name(),
                config.getRegion(),
                InetAddress.getLocalHost().getHostName(),
                config.getVersion(),
                getWorkerCapabilities(),
                Map.of(
                    "startTime", Instant.now(),
                    "javaVersion", System.getProperty("java.version")
                )
            );
            registryClient.register(registration);
        } catch (RegistryException e) {
            log.warn("Registry registration failed, continuing", e);
        }
    }
    

    7.2 Heartbeat périodique

    // Dans la boucle principale ou via ScheduledExecutorService
    private void sendRegistryHeartbeat() {
        if (registryClient == null || !registryClient.isRegistered()) {
            return;
        }
    
        try {
            WorkerHeartbeat heartbeat = new WorkerHeartbeat(
                config.getExec_id(),
                "RUNNING",
                Map.of(
                    "uptime", getUptime(),
                    "workersCount", workers.size(),
                    "healthyWorkers", countHealthyWorkers(),
                    "memoryUsedMb", getMemoryUsedMb()
                )
            );
            registryClient.heartbeat(heartbeat);
        } catch (RegistryException e) {
            log.debug("Heartbeat failed: {}", e.getMessage());
        }
    }
    

    7.3 Désenregistrement à l’arrêt

    // Dans MainOrchestratorProcess.gracefulShutdown()
    if (registryClient != null && registryClient.isRegistered()) {
        log.info("[step:registry_unregister] Désenregistrement du Registry");
        try {
            registryClient.unregister(config.getExec_id());
        } catch (RegistryException e) {
            log.warn("Unregister failed", e);
        }
    }
    

    8. Exemple de données

    8.1 Registration

    {
      "workerId": "AGENT-DB2-MTQ-001",
      "workerType": "journal-reader",
      "region": "MTQ",
      "host": "mtq-nuc-01",
      "version": "4.0.0",
      "capabilities": ["db2-cdc", "nats-publisher"],
      "extra": {
        "journal": "QUSR0023",
        "library": "IMA001FDMQ",
        "startTime": "2025-12-09T10:00:00Z",
        "javaVersion": "21.0.1"
      }
    }
    

    8.2 Heartbeat

    {
      "workerId": "AGENT-DB2-MTQ-001",
      "status": "RUNNING",
      "load": {
        "uptime": 3600,
        "messagesPerMinute": 523,
        "lastSequence": 123456789,
        "memoryUsedMb": 256,
        "cpuPercent": 15
      }
    }
    

    9. Côté serveur (Registry central)

    9.1 Table worker_registry

    CREATE TABLE worker_registry (
        id BIGSERIAL PRIMARY KEY,
        worker_id VARCHAR(200) NOT NULL UNIQUE,
        worker_type VARCHAR(100) NOT NULL,
        region VARCHAR(50),
        host VARCHAR(200),
        version VARCHAR(50),
        status VARCHAR(20) DEFAULT 'UNKNOWN',
        registered_at TIMESTAMPTZ DEFAULT NOW(),
        last_heartbeat TIMESTAMPTZ,
        capabilities JSONB,
        extra JSONB,
        load JSONB
    );
    
    CREATE INDEX idx_worker_registry_region ON worker_registry(region);
    CREATE INDEX idx_worker_registry_type ON worker_registry(worker_type);
    CREATE INDEX idx_worker_registry_status ON worker_registry(status);
    

    9.2 Détection des workers LOST

    -- Workers sans heartbeat depuis plus de 2 minutes
    UPDATE worker_registry
    SET status = 'LOST'
    WHERE status = 'RUNNING'
      AND last_heartbeat < NOW() - INTERVAL '2 minutes';
    

    9.3 Dashboard Metabase/Grafana

    -- Workers actifs par région
    SELECT region, COUNT(*) as count
    FROM worker_registry
    WHERE status = 'RUNNING'
    GROUP BY region;
    
    -- Workers LOST
    SELECT worker_id, region, last_heartbeat
    FROM worker_registry
    WHERE status = 'LOST';
    

    10. Bonnes pratiques

    DO

    • ✅ Enregistrer au démarrage, désenregistrer à l’arrêt
    • ✅ Heartbeat régulier (30s recommandé)
    • ✅ Inclure des métriques utiles dans le heartbeat
    • ✅ Gérer gracieusement les échecs (non bloquant)

    DON’T

    • ❌ Heartbeat trop fréquent (< 10s)
    • ❌ Bloquer sur les erreurs registry
    • ❌ Stocker des données sensibles dans extra

    11. Troubleshooting

    Worker non visible dans le dashboard

    1. Vérifier WORKER_REGISTRY_ENABLED=true
    2. Vérifier WORKER_REGISTRY_URL
    3. Vérifier les logs : « Worker registered successfully »

    Worker marqué LOST

    1. Vérifier que l’application tourne
    2. Vérifier la connectivité réseau
    3. Vérifier les logs heartbeat

    Erreur 401

    1. Vérifier que AUTH_ENABLED=true
    2. Vérifier API_KEY
    3. Vérifier que l’auth fonctionne

    12. Références

  • Socle V004 – Client Authentification

    Socle V004 – Client Authentification

    23 – Client Authentification JWT (Nouveauté V4)

    Version : 4.0.0 Date : 2025-12-09

    1. Introduction

    Le SocleAuthClient est un client d’authentification JWT intégré au Socle V4 pour communiquer avec les services centraux (LogHub, Registry, etc.).

    Pattern d’authentification

    ┌─────────────────┐                    ┌─────────────────┐
    │   Application   │                    │  Auth Server    │
    │   Socle V4      │                    │  (central)      │
    └────────┬────────┘                    └────────┬────────┘
             │                                      │
             │  1. POST /auth/login                 │
             │     {sourceName, apiKey}             │
             │─────────────────────────────────────►│
             │                                      │
             │  2. {accessToken, refreshToken}      │
             │◄─────────────────────────────────────│
             │                                      │
             │  3. Requêtes avec Bearer token       │
             │  Authorization: Bearer <accessToken> │
             │─────────────────────────────────────►│ Services
             │                                      │
             │  4. POST /auth/refresh (auto)        │
             │     {refreshToken}                   │
             │─────────────────────────────────────►│
             │                                      │
             │  5. {accessToken (new)}              │
             │◄─────────────────────────────────────│
    

    2. Configuration

    2.1 application.yml

    socle:
      auth:
        enabled: ${AUTH_ENABLED:false}
        server-url: ${AUTH_SERVER_URL:https://auth.lmvi.org}
        source-name: ${SOURCE_NAME:${socle.app_name}}
        api-key: ${API_KEY:}
        access-token-buffer-seconds: 60
        connect-timeout-ms: 10000
        read-timeout-ms: 30000
    

    2.2 Variables d’environnement

    Variable Description Défaut
    AUTH_ENABLED Activer l’authentification false
    AUTH_SERVER_URL URL du serveur d’auth
    SOURCE_NAME Identifiant du client ${APP_NAME}
    API_KEY Clé API (secret)

    3. Interface SocleAuthClient

    package eu.lmvi.socle.client.auth;
    
    /**
     * Client d'authentification Socle V4
     */
    public interface SocleAuthClient {
    
        /**
         * Login initial avec API Key
         * @return Tokens d'accès et de refresh
         * @throws AuthenticationException si échec
         */
        AuthTokens login() throws AuthenticationException;
    
        /**
         * Refresh du token d'accès
         * @param refreshToken Token de refresh
         * @return Nouveaux tokens
         * @throws AuthenticationException si échec
         */
        AuthTokens refresh(String refreshToken) throws AuthenticationException;
    
        /**
         * Obtenir un token d'accès valide (avec refresh auto si nécessaire)
         * @return Token d'accès valide
         * @throws AuthenticationException si échec
         */
        String getValidAccessToken() throws AuthenticationException;
    
        /**
         * Vérifie si le client est authentifié
         * @return true si un token valide existe
         */
        boolean isAuthenticated();
    
        /**
         * Invalide les tokens courants
         */
        void logout();
    }
    

    4. DTOs

    4.1 AuthTokens

    package eu.lmvi.socle.client.auth;
    
    public record AuthTokens(
        String accessToken,
        String refreshToken,
        Instant accessTokenExpiry,
        Instant refreshTokenExpiry
    ) {
        public boolean isAccessTokenExpired() {
            return Instant.now().isAfter(accessTokenExpiry);
        }
    
        public boolean isAccessTokenExpiringSoon(int bufferSeconds) {
            return Instant.now().plusSeconds(bufferSeconds).isAfter(accessTokenExpiry);
        }
    
        public boolean isRefreshTokenExpired() {
            return Instant.now().isAfter(refreshTokenExpiry);
        }
    }
    

    4.2 LoginRequest / LoginResponse

    // Request
    public record LoginRequest(
        String sourceName,
        String apiKey
    ) {}
    
    // Response
    public record LoginResponse(
        String accessToken,
        String refreshToken,
        int expiresIn,        // secondes
        int refreshExpiresIn  // secondes
    ) {}
    

    5. Implémentation AuthTokenManager

    package eu.lmvi.socle.client.auth;
    
    @Component
    @ConditionalOnProperty(name = "socle.auth.enabled", havingValue = "true")
    public class AuthTokenManager implements SocleAuthClient {
    
        private static final Logger log = LoggerFactory.getLogger(AuthTokenManager.class);
    
        private final SocleConfiguration config;
        private final OkHttpClient httpClient;
        private final ObjectMapper objectMapper;
    
        private volatile AuthTokens currentTokens;
        private final ReentrantLock refreshLock = new ReentrantLock();
    
        public AuthTokenManager(SocleConfiguration config) {
            this.config = config;
            this.objectMapper = new ObjectMapper();
            this.objectMapper.registerModule(new JavaTimeModule());
    
            this.httpClient = new OkHttpClient.Builder()
                .connectTimeout(config.getAuthConnectTimeoutMs(), TimeUnit.MILLISECONDS)
                .readTimeout(config.getAuthReadTimeoutMs(), TimeUnit.MILLISECONDS)
                .build();
        }
    
        @Override
        public AuthTokens login() throws AuthenticationException {
            log.info("Login to auth server: {}", config.getAuthServerUrl());
    
            LoginRequest request = new LoginRequest(
                config.getSourceName(),
                config.getApiKey()
            );
    
            try {
                String json = objectMapper.writeValueAsString(request);
    
                Request httpRequest = new Request.Builder()
                    .url(config.getAuthServerUrl() + "/api/v1/auth/login")
                    .post(RequestBody.create(json, MediaType.parse("application/json")))
                    .build();
    
                try (Response response = httpClient.newCall(httpRequest).execute()) {
                    if (!response.isSuccessful()) {
                        throw new AuthenticationException("Login failed: " + response.code());
                    }
    
                    LoginResponse loginResponse = objectMapper.readValue(
                        response.body().string(),
                        LoginResponse.class
                    );
    
                    currentTokens = new AuthTokens(
                        loginResponse.accessToken(),
                        loginResponse.refreshToken(),
                        Instant.now().plusSeconds(loginResponse.expiresIn()),
                        Instant.now().plusSeconds(loginResponse.refreshExpiresIn())
                    );
    
                    log.info("Login successful, token expires in {} seconds", loginResponse.expiresIn());
                    return currentTokens;
                }
            } catch (IOException e) {
                throw new AuthenticationException("Login failed", e);
            }
        }
    
        @Override
        public AuthTokens refresh(String refreshToken) throws AuthenticationException {
            log.debug("Refreshing access token");
    
            RefreshRequest request = new RefreshRequest(refreshToken);
    
            try {
                String json = objectMapper.writeValueAsString(request);
    
                Request httpRequest = new Request.Builder()
                    .url(config.getAuthServerUrl() + "/api/v1/auth/refresh")
                    .post(RequestBody.create(json, MediaType.parse("application/json")))
                    .build();
    
                try (Response response = httpClient.newCall(httpRequest).execute()) {
                    if (!response.isSuccessful()) {
                        // Refresh failed, need to re-login
                        log.warn("Refresh failed, attempting re-login");
                        return login();
                    }
    
                    RefreshResponse refreshResponse = objectMapper.readValue(
                        response.body().string(),
                        RefreshResponse.class
                    );
    
                    currentTokens = new AuthTokens(
                        refreshResponse.accessToken(),
                        currentTokens.refreshToken(),  // Keep same refresh token
                        Instant.now().plusSeconds(refreshResponse.expiresIn()),
                        currentTokens.refreshTokenExpiry()
                    );
    
                    log.debug("Token refreshed, new expiry in {} seconds", refreshResponse.expiresIn());
                    return currentTokens;
                }
            } catch (IOException e) {
                throw new AuthenticationException("Refresh failed", e);
            }
        }
    
        @Override
        public String getValidAccessToken() throws AuthenticationException {
            // First time - login
            if (currentTokens == null) {
                login();
                return currentTokens.accessToken();
            }
    
            // Refresh token expired - need full re-login
            if (currentTokens.isRefreshTokenExpired()) {
                log.info("Refresh token expired, re-login required");
                login();
                return currentTokens.accessToken();
            }
    
            // Access token expiring soon - refresh
            int bufferSeconds = config.getAccessTokenBufferSeconds();
            if (currentTokens.isAccessTokenExpiringSoon(bufferSeconds)) {
                refreshLock.lock();
                try {
                    // Double-check after acquiring lock
                    if (currentTokens.isAccessTokenExpiringSoon(bufferSeconds)) {
                        refresh(currentTokens.refreshToken());
                    }
                } finally {
                    refreshLock.unlock();
                }
            }
    
            return currentTokens.accessToken();
        }
    
        @Override
        public boolean isAuthenticated() {
            return currentTokens != null && !currentTokens.isAccessTokenExpired();
        }
    
        @Override
        public void logout() {
            currentTokens = null;
            log.info("Logged out");
        }
    }
    

    6. Utilisation

    6.1 Injection

    @Service
    public class MonService {
    
        @Autowired(required = false)
        private SocleAuthClient authClient;
    
        public void callSecuredApi() throws Exception {
            if (authClient == null || !authClient.isAuthenticated()) {
                throw new IllegalStateException("Auth not configured");
            }
    
            String token = authClient.getValidAccessToken();
    
            // Utiliser le token
            Request request = new Request.Builder()
                .url("https://api.mycompany.com/secured")
                .header("Authorization", "Bearer " + token)
                .build();
    
            // ...
        }
    }
    

    6.2 Intégration avec LogForwarder

    // Dans HttpLogTransport
    public class HttpLogTransport implements LogTransport {
    
        private final SocleAuthClient authClient;
    
        @Override
        public void send(List<LogEntry> entries) throws Exception {
            String token = authClient.getValidAccessToken();
    
            Request request = new Request.Builder()
                .url(logHubUrl)
                .header("Authorization", "Bearer " + token)
                .post(RequestBody.create(toJson(entries), JSON))
                .build();
    
            // ...
        }
    }
    

    6.3 Intégration avec MOP

    // Dans MainOrchestratorProcess.start()
    if (authClient != null && config.isAuthEnabled()) {
        log.info("[step:auth] Login auprès du serveur d'auth");
        try {
            authClient.login();
        } catch (AuthenticationException e) {
            log.error("Auth failed, continuing without auth", e);
        }
    }
    

    7. Gestion des erreurs

    7.1 AuthenticationException

    public class AuthenticationException extends Exception {
        public AuthenticationException(String message) {
            super(message);
        }
    
        public AuthenticationException(String message, Throwable cause) {
            super(message, cause);
        }
    }
    

    7.2 Retry automatique

    Le AuthTokenManager gère automatiquement :

    • Le refresh avant expiration
    • Le re-login si le refresh échoue
    • Le re-login si le refresh token expire

    7.3 Fallback sans auth

    if (authClient == null) {
        log.warn("Auth not configured, proceeding without authentication");
        // Continuer sans auth (pour dev local)
    }
    

    8. Sécurité

    8.1 Stockage de l’API Key

    # NE PAS mettre dans le code
    # Utiliser des variables d'environnement
    export API_KEY="xxx-secret-key"
    
    # Ou un gestionnaire de secrets
    # Kubernetes Secret, AWS Secrets Manager, etc.
    

    8.2 Tokens en mémoire

    Les tokens sont stockés en mémoire uniquement :

    • Jamais persistés sur disque
    • Invalidés au restart
    • Thread-safe

    8.3 HTTPS obligatoire

    socle:
      auth:
        server-url: https://auth.mycompany.com  # HTTPS obligatoire
    

    9. Monitoring

    9.1 Métriques

    // Exposées via /metrics
    socle_auth_login_total          # Nombre de logins
    socle_auth_login_errors_total   # Erreurs de login
    socle_auth_refresh_total        # Nombre de refresh
    socle_auth_token_expiry_seconds # Temps avant expiration
    

    9.2 Logs

    INFO  - Login to auth server: https://auth.lmvi.org
    INFO  - Login successful, token expires in 900 seconds
    DEBUG - Refreshing access token
    DEBUG - Token refreshed, new expiry in 900 seconds
    WARN  - Refresh failed, attempting re-login
    

    10. Troubleshooting

    Connection refused

    AuthenticationException: Login failed: Connection refused
    

    Vérifier :

    • AUTH_SERVER_URL est correct
    • Le serveur d’auth est accessible
    • Les ports sont ouverts

    Invalid API Key

    AuthenticationException: Login failed: 401
    

    Vérifier :

    • API_KEY est correct
    • SOURCE_NAME est enregistré côté serveur

    Token expired

    Si les tokens expirent trop vite :

    • Vérifier l’horloge système (NTP)
    • Augmenter access-token-buffer-seconds

    11. Références

  • Socle V004 – Plan de Documentation

    Socle V004 – Plan de Documentation

    Plan de Documentation – Socle V4

    Version : 4.0.0 Date : 2025-01-25

    Structure de la documentation

    # Document Description Statut
    00 PLAN-DOCUMENTATION Ce document – Index de la documentation Done
    01 INTRODUCTION Présentation du Socle V4 et philosophie Done
    02 ARCHITECTURE Architecture technique et composants Done
    03 QUICKSTART Guide de démarrage rapide (5 min) Done
    04 CONFIGURATION Référence complète de configuration Done
    05 WORKERS Guide des Workers et lifecycle Done
    06 KV-BUS Guide du Key-Value Bus Done
    07 SHARED-DATA Guide du SharedDataRegistry Done
    08 SUPERVISOR Supervision et heartbeats Done
    09 PIPELINE Pipeline Engine V1 et V2 (Queue/Claim/Ack, DLQ) Done
    10 SECURITY Sécurité, Auth, Rate Limiting Done
    11 RESILIENCE Circuit Breaker et Retry Done
    12 SCHEDULER Scheduling cron et interval Done
    13 TLS-HTTPS Configuration TLS/HTTPS Done
    14 ADMIN-API API REST d’administration Done
    15 METRICS Métriques et Prometheus Done
    16 KUBERNETES Déploiement Kubernetes Done
    17 HOWTO Guides pratiques Done
    18 TROUBLESHOOTING Résolution de problèmes Done
    19 EXEMPLES Exemples de code Done
    20 PLUGINS Système de plugins Done
    21 H2-TECHDB Base technique H2 (V4) Done
    22 LOG4J2-LOGFORWARDER Log4j2 et LogForwarder (V4) Done
    23 AUTH-CLIENT Client authentification JWT (V4) Done
    24 WORKER-REGISTRY Client Worker Registry (V4) Done
    25 MIGRATION-V3-V4 Guide de migration V3 → V4 Done
    26 GRAALVM-JAVASCRIPT GraalVM CE et GraalJS pour scripts JS Done
    27 STATUS-DASHBOARD Dashboard HTML de supervision (port 9374) Done
    29 JANINO Scripts Java compiles dynamiquement Done
    30 EVENTBUS-WORKERS Workers event-driven Done
    31 GRPC-INTER-SOCLES Communication gRPC entre Socles Done

    Nouveautés V4

    Les documents 21 à 30 sont spécifiques au Socle V4 :

    • 21-H2-TECHDB : Base embarquée H2 pour état technique
    • 22-LOG4J2-LOGFORWARDER : Migration Logback → Log4j2 + centralisation logs
    • 23-AUTH-CLIENT : Client JWT pour services centraux
    • 24-WORKER-REGISTRY : Auto-enregistrement des workers
    • 25-MIGRATION-V3-V4 : Guide de migration depuis V3
    • 26-GRAALVM-JAVASCRIPT : GraalVM CE 21 et GraalJS pour exécution JavaScript
    • 27-STATUS-DASHBOARD : Dashboard HTML de supervision temps réel sur port 9374
    • 29-JANINO : Compilation dynamique de scripts Java
    • 30-EVENTBUS-WORKERS : Workers orientés événements
    • 31-GRPC-INTER-SOCLES : Communication gRPC bidirectionnelle entre Socles

    Guide Méthodologique

    Un guide méthodologique complet est disponible pour aider les développeurs à implémenter leurs solutions :

    Ce document répond à la question : « Je dois implémenter X, comment je fais ? »

    Conventions

    • Tous les fichiers sont en Markdown
    • Les exemples de code sont en Java 21
    • Les configurations sont en YAML
    • Les commandes sont pour Linux/macOS (adaptables Windows)
  • Socle V004 – API Administration

    Socle V004 – API Administration

    14 – Admin API

    Version : 4.0.0 Date : 2025-12-09

    1. Introduction

    L’Admin API expose des endpoints REST pour l’administration et le monitoring du Socle V4.

    Endpoints principaux

    Endpoint Description
    /admin/health État de santé
    /admin/workers État des workers
    /admin/config Configuration
    /admin/registry SharedDataRegistry
    /admin/metrics Métriques

    2. Configuration

    2.1 application.yml

    socle:
      admin:
        enabled: ${ADMIN_ENABLED:true}
        path-prefix: ${ADMIN_PATH_PREFIX:/admin}
        auth:
          enabled: ${ADMIN_AUTH_ENABLED:false}
          username: ${ADMIN_USERNAME:admin}
          password: ${ADMIN_PASSWORD:}
    

    2.2 Variables d’environnement

    Variable Description Défaut
    ADMIN_ENABLED Activer l’API admin true
    ADMIN_PATH_PREFIX Préfixe des endpoints /admin
    ADMIN_AUTH_ENABLED Activer l’authentification false
    ADMIN_USERNAME Utilisateur admin admin
    ADMIN_PASSWORD Mot de passe admin

    3. Endpoints Health

    3.1 GET /admin/health

    État de santé global de l’application.

    Réponse :

    {
      "status": "HEALTHY",
      "timestamp": "2025-12-09T10:30:00Z",
      "uptime": "2h 30m 15s",
      "unhealthyWorkers": [],
      "components": {
        "database": "UP",
        "redis": "UP",
        "techdb": "UP"
      }
    }
    

    Codes HTTP :

    • 200 : HEALTHY
    • 503 : UNHEALTHY ou DEGRADED

    3.2 GET /admin/health/live

    Liveness probe pour Kubernetes.

    {
      "status": "UP"
    }
    

    3.3 GET /admin/health/ready

    Readiness probe pour Kubernetes.

    {
      "status": "READY",
      "checks": {
        "workers": "OK",
        "database": "OK"
      }
    }
    

    4. Endpoints Workers

    4.1 GET /admin/workers

    Liste tous les workers et leur état.

    {
      "workers": [
        {
          "name": "kafka-consumer",
          "status": "RUNNING",
          "lastHeartbeat": "2025-12-09T10:29:55Z",
          "healthy": true,
          "stats": {
            "processed": 12345,
            "errors": 2
          }
        },
        {
          "name": "order-processor",
          "status": "RUNNING",
          "lastHeartbeat": "2025-12-09T10:29:58Z",
          "healthy": true,
          "stats": {
            "ordersProcessed": 567
          }
        }
      ],
      "total": 2,
      "healthy": 2,
      "unhealthy": 0
    }
    

    4.2 GET /admin/workers/{name}

    Détails d’un worker spécifique.

    {
      "name": "kafka-consumer",
      "status": "RUNNING",
      "healthy": true,
      "startPriority": 10,
      "stopPriority": 90,
      "scheduled": false,
      "passive": false,
      "cycleIntervalMs": 1000,
      "lastHeartbeat": "2025-12-09T10:29:55Z",
      "stats": {
        "processed": 12345,
        "errors": 2,
        "lastOffset": 98765
      }
    }
    

    4.3 POST /admin/workers/{name}/stop

    Arrête un worker spécifique.

    curl -X POST http://localhost:8080/admin/workers/kafka-consumer/stop
    

    4.4 POST /admin/workers/{name}/start

    Redémarre un worker arrêté.

    curl -X POST http://localhost:8080/admin/workers/kafka-consumer/start
    

    5. Endpoints Configuration

    5.1 GET /admin/config

    Configuration actuelle (sans secrets).

    {
      "appName": "socle-v4",
      "envName": "PROD",
      "region": "MTQ",
      "version": "4.0.0",
      "execId": "socle-v4-abc123",
      "kvbus": {
        "mode": "redis"
      },
      "techdb": {
        "enabled": true
      },
      "logging": {
        "forwarder": {
          "enabled": true,
          "transportMode": "http"
        }
      }
    }
    

    5.2 GET /admin/config/env

    Variables d’environnement (filtrées).

    {
      "APP_NAME": "socle-v4",
      "ENV_NAME": "PROD",
      "REGION": "MTQ",
      "HTTP_PORT": "8080",
      "KVBUS_MODE": "redis"
    }
    

    6. Endpoints Registry

    6.1 GET /admin/registry

    Contenu du SharedDataRegistry.

    {
      "database.connected": true,
      "metrics.requests.total": 12345,
      "metrics.errors.total": 23,
      "batch.current.id": "batch-001",
      "worker.kafka.offset": 98765
    }
    

    6.2 GET /admin/registry/{key}

    Valeur d’une clé spécifique.

    {
      "key": "metrics.requests.total",
      "value": 12345,
      "healthLevel": "NORMAL"
    }
    

    6.3 GET /admin/registry/health

    Clés avec leur niveau de santé.

    {
      "database.connected": "CRITICAL",
      "cache.available": "IMPORTANT",
      "metrics.requests.total": "INFO"
    }
    

    6.4 GET /admin/registry/unhealthy

    Clés en état unhealthy.

    [
      {
        "key": "external.api.available",
        "value": false,
        "healthLevel": "CRITICAL"
      }
    ]
    

    7. Endpoints TechDB (V4)

    7.1 GET /admin/techdb/offsets

    Tous les offsets stockés.

    {
      "offsets": [
        {
          "sourceName": "kafka",
          "partitionKey": "orders-topic-0",
          "lastSequence": 123456,
          "lastUpdated": "2025-12-09T10:30:00Z"
        },
        {
          "sourceName": "nats",
          "partitionKey": "events.orders",
          "lastSequence": 789012,
          "lastUpdated": "2025-12-09T10:29:55Z"
        }
      ]
    }
    

    7.2 GET /admin/techdb/workers

    État des workers persisté.

    {
      "workers": [
        {
          "workerId": "kafka-consumer-001",
          "status": "RUNNING",
          "lastHeartbeat": "2025-12-09T10:30:00Z",
          "metadata": {
            "messagesPerMinute": 523
          }
        }
      ]
    }
    

    7.3 GET /admin/techdb/events

    Événements techniques récents.

    {
      "events": [
        {
          "id": 123,
          "createdAt": "2025-12-09T10:25:00Z",
          "type": "PIPELINE_ERROR",
          "payload": {
            "pipeline": "order-processing",
            "error": "Connection timeout"
          }
        }
      ]
    }
    

    8. Endpoints Resilience

    8.1 GET /admin/resilience/circuits

    État des circuit breakers.

    {
      "circuits": {
        "payment-gateway": "CLOSED",
        "inventory-api": "HALF_OPEN",
        "notification-service": "OPEN"
      }
    }
    

    8.2 POST /admin/resilience/circuits/{name}/reset

    Reset un circuit breaker.

    curl -X POST http://localhost:8080/admin/resilience/circuits/notification-service/reset
    

    9. Endpoints Scheduler

    9.1 GET /admin/scheduler/jobs

    Jobs schedulés.

    {
      "jobs": [
        {
          "jobId": "worker:daily-report",
          "type": "cron",
          "schedule": "0 0 6 * * ?",
          "scheduledAt": "2025-12-09T06:00:00Z"
        },
        {
          "jobId": "worker:health-check",
          "type": "interval",
          "intervalMs": 30000,
          "scheduledAt": "2025-12-09T10:00:00Z"
        }
      ]
    }
    

    9.2 POST /admin/scheduler/jobs/{jobId}/trigger

    Déclenche un job immédiatement.

    curl -X POST http://localhost:8080/admin/scheduler/jobs/worker:daily-report/trigger
    

    10. Endpoints LogForwarder (V4)

    10.1 GET /admin/logforwarder/status

    État du LogForwarder.

    {
      "enabled": true,
      "transportMode": "http",
      "queueSize": 23,
      "queueCapacity": 10000,
      "fallbackCount": 0,
      "lastFlush": "2025-12-09T10:29:55Z"
    }
    

    10.2 POST /admin/logforwarder/flush

    Force le flush des logs.

    curl -X POST http://localhost:8080/admin/logforwarder/flush
    

    10.3 POST /admin/logforwarder/replay

    Rejoue les logs en fallback.

    curl -X POST http://localhost:8080/admin/logforwarder/replay
    

    11. Implémentation

    package eu.lmvi.socle.admin;
    
    @RestController
    @RequestMapping("${socle.admin.path-prefix:/admin}")
    public class AdminRestApi {
    
        @Autowired private Supervisor supervisor;
        @Autowired private SharedDataRegistry registry;
        @Autowired private SocleConfiguration config;
        @Autowired(required = false) private TechDbManager techDb;
        @Autowired(required = false) private Scheduler scheduler;
    
        // === Health ===
    
        @GetMapping("/health")
        public ResponseEntity<HealthResponse> health() {
            HealthStatus status = supervisor.getGlobalHealth();
            HttpStatus httpStatus = status == HealthStatus.HEALTHY
                ? HttpStatus.OK
                : HttpStatus.SERVICE_UNAVAILABLE;
    
            return ResponseEntity.status(httpStatus).body(new HealthResponse(
                status,
                Instant.now(),
                getUptime(),
                supervisor.getUnhealthyWorkers()
            ));
        }
    
        @GetMapping("/health/live")
        public ResponseEntity<Map<String, String>> live() {
            return ResponseEntity.ok(Map.of("status", "UP"));
        }
    
        @GetMapping("/health/ready")
        public ResponseEntity<Map<String, Object>> ready() {
            HealthStatus status = supervisor.getGlobalHealth();
            if (status != HealthStatus.HEALTHY) {
                return ResponseEntity.status(503).body(Map.of(
                    "status", "NOT_READY",
                    "unhealthy", supervisor.getUnhealthyWorkers()
                ));
            }
            return ResponseEntity.ok(Map.of("status", "READY"));
        }
    
        // === Workers ===
    
        @GetMapping("/workers")
        public Map<String, Object> workers() {
            Map<String, WorkerState> states = supervisor.getAllWorkerStates();
            return Map.of(
                "workers", states.values(),
                "total", states.size(),
                "healthy", states.values().stream().filter(WorkerState::isHealthy).count(),
                "unhealthy", states.values().stream().filter(s -> !s.isHealthy()).count()
            );
        }
    
        @GetMapping("/workers/{name}")
        public ResponseEntity<WorkerState> worker(@PathVariable String name) {
            WorkerState state = supervisor.getWorkerState(name);
            return state != null
                ? ResponseEntity.ok(state)
                : ResponseEntity.notFound().build();
        }
    
        // === Config ===
    
        @GetMapping("/config")
        public Map<String, Object> config() {
            return Map.of(
                "appName", config.getApp_name(),
                "envName", config.getEnv_name(),
                "region", config.getRegion(),
                "version", config.getVersion(),
                "execId", config.getExec_id()
            );
        }
    
        // === Registry ===
    
        @GetMapping("/registry")
        public Map<String, Object> registry() {
            return registry.getAll();
        }
    
        @GetMapping("/registry/{key}")
        public ResponseEntity<Map<String, Object>> registryKey(@PathVariable String key) {
            return registry.get(key)
                .map(v -> ResponseEntity.ok(Map.of(
                    "key", key,
                    "value", v,
                    "healthLevel", registry.getHealthLevel(key)
                )))
                .orElse(ResponseEntity.notFound().build());
        }
    
        // ... autres endpoints
    }
    

    12. Sécurité

    12.1 Authentification Basic

    # Avec authentification
    curl -u admin:secret http://localhost:8080/admin/workers
    
    # En-tête Authorization
    curl -H "Authorization: Basic YWRtaW46c2VjcmV0" http://localhost:8080/admin/workers
    

    12.2 Endpoints publics

    Les endpoints suivants sont accessibles sans authentification :

    • /admin/health
    • /admin/health/live
    • /admin/health/ready

    13. Bonnes pratiques

    DO

    • Activer l’authentification en production
    • Utiliser HTTPS pour l’API admin
    • Limiter l’accès réseau à l’API admin
    • Monitorer les accès à l’API admin

    DON’T

    • Ne pas exposer l’API admin publiquement
    • Ne pas désactiver l’authentification en production
    • Ne pas logger les credentials

    14. Références