Skip to content

Latest commit

 

History

History
1041 lines (805 loc) · 38.8 KB

File metadata and controls

1041 lines (805 loc) · 38.8 KB

Guide d'implémentation de plugin GhostDrive

Version : v0.7.0 Audience : développeurs Go (humains et agents Claude Code) Temps estimé : 2-4 h pour un backend simple (filesystem, HTTP)


1. Vue d'ensemble

Deux architectures de plugin

GhostDrive supporte deux modes de plugin :

Plugins statiques

Compilés directement dans le binaire GhostDrive. Actuellement, seul le plugin "local" (plugins/local/) est construit de cette manière. La factory dans plugins/registry.go mappe les noms de type aux fonctions constructeurs.

Plugins dynamiques (go-plugin + gRPC)

N'importe quel backend compilé comme un binaire standalone et placé dans <AppDir>/plugins/. Le loader (plugins/loader/grpc_loader.go) découvre ces binaires au démarrage, négocie la poignée de main go-plugin (plugins/loader.HandshakeConfig), et fait le pont entre chaque plugin via le transport gRPC défini dans plugins/proto/storage.proto.

Les développeurs de plugins doivent commencer par plugins/sdk/go/ (exemple echo + Makefile) et implémenter StorageBackend dans leur propre binaire.

Flux architectural (plugins dynamiques)

┌─────────────────────────┐
│   GhostDrive binary     │
│                         │
│  ┌─────────────────────┐│
│  │   GRPCLoader        ││
│  │  (go-plugin client) ││
│  └──────────┬──────────┘│
└─────────────┼───────────┘
              │ (fork + exec)
              ↓
    ┌────────────────────────┐
    │  Plugin subprocess     │
    │ (standalone binary)    │
    │                        │
    │ ┌────────────────────┐ │
    │ │ StorageBackend     │ │
    │ │ (implementation)   │ │
    │ │                    │ │
    │ │ goplugin.Serve()   │ │
    │ └──────────┬─────────┘ │
    │            │ gRPC      │
    │  ┌─────────▼─────────┐ │
    │  │ Storage Service   │ │
    │  │ (grpc bridge)     │ │
    │  └───────────────────┘ │
    └────────────────────────┘
              ↕ gRPC + go-plugin handshake
         (via stdio pipe)

Cycle de vie d'un plugin dynamique

GRPCLoader.Scan()
  ├── Découvre binaires dans <AppDir>/plugins/
  ├── Lance chaque binaire en subprocess
  ├── Négocie le handshake go-plugin
  │   - Vérifie cookie GHOSTDRIVE_PLUGIN = storage.v1
  │   - Établit le protocole gRPC
  ├── Appelle Name() pour enregistrer dans la factory
  ├── Watchdog : redémarre en cas de crash
  │   - Délais par défaut : 1s → 2s → 4s (3 tentatives max)
  │   - Après N crashes, marque comme "failed"
  └── À Disconnect() : termine le subprocess

Cycle d'une instance backend :
  Connect(config) → Watch / Upload / Download / Delete / Move / Stat / List / CreateDir → Disconnect()

2. L'interface StorageBackend — référence complète

Source : plugins/plugin.go

Types partagés

FileInfo

type FileInfo struct {
    // Name est le nom de base de l'entrée (sans séparateur).
    Name string
    // Path est le chemin slash-séparé retourné par le backend,
    // relatif à la racine RemotePath. Ne commence jamais avec une lettre de lecteur.
    Path string
    // Size est la taille en octets du contenu du fichier. Zéro pour les répertoires.
    Size int64
    // IsDir vaut true quand l'entrée est un répertoire.
    IsDir bool
    // ModTime est l'horodatage de dernière modification rapporté par le backend.
    ModTime time.Time
    // ETag est la balise HTTP ou un token de version équivalent, si disponible.
    ETag string
    // IsPlaceholder indique que le fichier est un placeholder Files-On-Demand
    // (contenu non encore hydraté localement).
    IsPlaceholder bool
    // IsCached indique que le contenu du fichier est présent dans le cache local.
    IsCached bool
}

FileEvent / FileEventType

type FileEventType string

const (
    FileEventCreated  FileEventType = "created"   // Nouveau fichier ou répertoire
    FileEventModified FileEventType = "modified"  // Contenu ou métadonnées changés
    FileEventDeleted  FileEventType = "deleted"   // Entrée supprimée
    FileEventRenamed  FileEventType = "renamed"   // Entrée déplacée
)

type FileEvent struct {
    Type      FileEventType // Type de changement
    Path      string        // Chemin slash-séparé de l'entrée affectée
    OldPath   string        // Chemin précédent pour FileEventRenamed ; vide sinon
    Timestamp time.Time     // Quand le changement a été détecté
    Source    string        // "local" pour les changements locaux, "remote" pour le backend
}

BackendConfig

type BackendConfig struct {
    ID       string            // Identifiant unique de cette instance backend
    Name     string            // Label affiché dans l'UI
    Type     string            // Sélectionne le plugin ("webdav", "moosefs", "echo", etc.)
    Enabled  bool              // Participe ou non à la sync
    AutoSync bool              // Démarre auto la sync au Connect (défaut : false)
    Params   map[string]string // Config spécifique au plugin (voir contracts/backend-config.md)
    SyncDir  string            // Deprecated : utiliser LocalPath à la place
    RemotePath string          // Racine sur le backend (ex: "/GhostDrive")
    LocalPath  string          // Point de sync local — destination GhostDrive sur le PC
    Warning   string           // Message de validation non-bloquant (loader-side only)
}

ProgressCallback

type ProgressCallback func(done, total int64)
// done : octets transférés jusqu'ici (monotoniquement croissant)
// total : taille totale attendue (-1 si inconnue)
// Le callback ne doit pas bloquer

Erreurs sentinelles

Sentinel Utilisation
plugins.ErrNotConnected N'importe quelle opération appelée sans connexion active
plugins.ErrFileNotFound Le chemin distant demandé n'existe pas

Convention de wrapping (critique) :

// ✓ Correct — permet errors.Is(err, plugins.ErrNotConnected)
return fmt.Errorf("myplugin: upload %s: %w", remote, plugins.ErrNotConnected)

// ✗ Incorrect — casse errors.Is et la propagation gRPC
return errors.New("myplugin: not connected")

Méthodes de l'interface

Identification

Méthode Signature Contrat
Name() Name() string Retourne l'identifiant du plugin en minuscules (ex: "webdav", "echo"). Immutable ; peut être appelé avant Connect().

Connexion

Méthode Signature Contrat
Connect() Connect(config BackendConfig) error Initialise le backend avec la config fournie. Valide les Params obligatoires et probe le backend (ex: PROPFIND pour WebDAV, vérifier que le chemin existe pour local). Retourne une erreur descriptive si le backend est inaccessible ou mal configuré. Rappeler Connect sur un backend déjà connecté le reconnecter.
Disconnect() Disconnect() error Libère les ressources (connexions ouvertes, goroutines, etc.). Après Disconnect, toutes les opérations sauf Connect doivent retourner ErrNotConnected. Sans danger d'appeler sur un backend déjà déconnecté (no-op).
IsConnected() IsConnected() bool Retourne true si Connect a réussi et Disconnect n'a pas été appelé depuis. Thread-safe ; ne réalise pas d'I/O.

Opérations fichiers

Méthode Signature Contrat
Upload() Upload(ctx context.Context, local, remote string, progress ProgressCallback) error Copie le fichier local à local vers le chemin distant remote. Les répertoires intermédiaires ne sont PAS créés automatiquement ; appelez CreateDir d'abord si nécessaire. progress peut être nil. Retourne ErrNotConnected si pas connecté.
Download() Download(ctx context.Context, remote, local string, progress ProgressCallback) error Copie le fichier distant à remote vers le chemin local local. Le répertoire parent de local est créé s'il n'existe pas. Retourne ErrFileNotFound (wrapped) si remote n'existe pas.
Delete() Delete(ctx context.Context, remote string) error Supprime le fichier ou le répertoire à remote. Supprimer un répertoire non-vide est implémentation-défini (les plugins peuvent refuser ou supprimer récursivement). Retourne ErrFileNotFound (wrapped) si absent.
Move() Move(ctx context.Context, oldPath, newPath string) error Renomme ou déplace l'entrée à oldPath vers newPath sur le backend. Écrase newPath s'il existe déjà.

Navigation

Méthode Signature Contrat
List() List(ctx context.Context, path string) ([]FileInfo, error) Retourne les enfants directs du répertoire à path. L'entrée du répertoire lui-même n'est PAS incluse dans le résultat. Retourne une slice vide (jamais nil) si le répertoire est vide. Retourne ErrFileNotFound (wrapped) si path n'existe pas ou est un fichier.
Stat() Stat(ctx context.Context, path string) (*FileInfo, error) Retourne les métadonnées du fichier ou du répertoire à path. Retourne ErrFileNotFound (wrapped) si absent.
CreateDir() CreateDir(ctx context.Context, path string) error Crée le répertoire à path. No-op si le répertoire existe déjà (pas d'erreur). Les répertoires parents ne sont PAS créés ; utilisez des appels récursifs.

Surveillance

Méthode Signature Contrat
Watch() Watch(ctx context.Context, path string) (<-chan FileEvent, error) Démarre la surveillance de path pour les changements et émet les FileEvent sur le canal retourné. Le canal se ferme quand ctx est annulé. Les implémentations peuvent utiliser des notifications natives (inotify, FSEvents) ou du polling ; documentez l'approche et l'intervalle détectable minimum. La taille de buffer du canal doit être ≥ 64 pour absorber les bursts d'événements.

Quota

Méthode Signature Contrat
GetQuota() GetQuota(ctx context.Context) (free, total int64, err error) Retourne l'espace libre et total (en octets) du backend. Les plugins qui ne supportent pas la quota doivent retourner (-1, -1, nil) plutôt qu'une erreur. Retourne ErrNotConnected si pas connecté.

Range reads (v2.0+)

Méthode Signature Contrat
ReadAt() ReadAt(ctx context.Context, remote string, offset, length int64) ([]byte, error) Lit jusqu'à length octets du fichier distant remote à partir de l'octet offset. Retourne les octets lus ; len(data) ≤ length (peut être < length en fin de fichier). Utilisé pour l'hydratation progressive et le cache de chunks. Retourne ErrFileNotFound (wrapped) si le fichier n'existe pas. Pré-condition : IsConnected() == true, sinon retourne ErrNotConnected.
ChunkSize() ChunkSize() int64 Retourne la granularité naturelle d'I/O du backend en octets. Les valeurs typiques : 0 (défaut global — pas de granularité native), ou 67_108_864 (64 MiB pour MooseFS). Immutable ; peut être appelé avant Connect() ; ne doit pas effectuer d'I/O.

Valeurs de ChunkSize() par plugin :

Plugin ChunkSize Justification
webdav 0 Pas de granularité native — l'offset HTTP Range est libre
moosefs 67_108_864 (64 MiB) Taille de chunk natif MooseFS
local 0 Lecture POSIX libre
mock 0 No-op

Implémentation WebDAV ReadAt : utiliser l'en-tête HTTP Range: bytes=offset-offset+length-1 (RFC 7233). Le serveur peut répondre avec 206 Partial Content (lire le body) ou 200 OK (fallback : lire et trancher) ; certains serveurs WebDAV ne supportent pas Range.

Implémentation MooseFS ReadAt : utiliser mfsclient.Read(nodeID, uint64(offset), uint32(length)) natif — optimisé pour les lectures partielles dans les chunks.

Implémentation Local ReadAt : utiliser os.File.ReadAt(buf, offset) standard POSIX.

Introspection du plugin

Méthode Signature Contrat
Describe() Describe() PluginDescriptor Retourne un descripteur statique du plugin : type, displayName, description, et liste des paramètres (ParamSpec) requis pour la configuration. Callable AVANT Connect() — ne doit effectuer aucun I/O, aucune connexion au backend. Ne retourne jamais d'erreur : un descripteur minimal (avec juste Type = Name()) est toujours valide.

Types d'introspection

ParamType

Énumération des types de champs supportés dans la UI de configuration :

type ParamType string

const (
    ParamTypeString   ParamType = "string"   // Input texte standard
    ParamTypePassword ParamType = "password" // Input masqué (type="password")
    ParamTypePath     ParamType = "path"     // Bouton "Parcourir" + SelectDirectory
    ParamTypeSelect   ParamType = "select"   // Dropdown (Options requis)
    ParamTypeBool     ParamType = "bool"     // Checkbox
    ParamTypeNumber   ParamType = "number"   // Input numérique
)

ParamSpec

Description d'un paramètre de configuration :

type ParamSpec struct {
    Key         string    // Clé dans BackendConfig.Params (ex: "url", "username")
    Label       string    // Libellé UI (ex: "URL serveur WebDAV")
    Type        ParamType // Type de champ
    Required    bool      // Champ obligatoire
    Default     string    // Valeur pré-remplie (peut être vide)
    Placeholder string    // Hint affiché dans l'input
    Options     []string  // Utilisé seulement si Type == ParamTypeSelect
    HelpText    string    // Texte d'aide sous le champ (optionnel)
}

PluginDescriptor

Résumé statique du plugin, retourné par Describe() :

type PluginDescriptor struct {
    Type        string      // Même valeur que Name() (ex: "local", "webdav")
    DisplayName string      // Libellé affiché dans le sélecteur de type
    Description string      // Description courte (ex: "Synchronise via WebDAV")
    Params      []ParamSpec // Spécification des champs de configuration
}

Exemple : implémenter Describe() pour WebDAV

func (w *WebDAVBackend) Describe() plugins.PluginDescriptor {
    return plugins.PluginDescriptor{
        Type:        "webdav",
        DisplayName: "WebDAV",
        Description: "Synchronise via un serveur WebDAV (Nextcloud, ownCloud, NAS…)",
        Params: []plugins.ParamSpec{
            {
                Key:         "url",
                Label:       "URL serveur",
                Type:        plugins.ParamTypeString,
                Required:    true,
                Placeholder: "https://nas.local/dav",
                HelpText:    "Adresse complète du serveur WebDAV",
            },
            {
                Key:      "authType",
                Label:    "Authentification",
                Type:     plugins.ParamTypeSelect,
                Required: false,
                Default:  "basic",
                Options:  []string{"basic", "bearer"},
                HelpText: "Méthode d'authentification",
            },
            {
                Key:         "username",
                Label:       "Nom d'utilisateur",
                Type:        plugins.ParamTypeString,
                Required:    false,
                Placeholder: "admin",
            },
            {
                Key:      "password",
                Label:    "Mot de passe",
                Type:     plugins.ParamTypePassword,
                Required: false,
            },
            {
                Key:      "tlsSkipVerify",
                Label:    "Ignorer erreurs TLS",
                Type:     plugins.ParamTypeBool,
                Required: false,
                Default:  "false",
                HelpText: "Accepter les certificats auto-signés",
            },
            {
                Key:      "pollInterval",
                Label:    "Intervalle Watch (ms)",
                Type:     plugins.ParamTypeNumber,
                Required: false,
                Default:  "30000",
                HelpText: "Intervalle de polling PROPFIND (millisecondes)",
            },
        },
    }
}

3. Créer un plugin externe (go-plugin) — guide pas-à-pas

Étape 1 — Créer un module Go indépendant

Vous pouvez créer un plugin dans un dépôt séparé ou au sein du même repo via go.work.

Option A — Dépôt séparé :

mkdir my-plugin && cd my-plugin
go mod init github.com/myorg/my-plugin

Option B — Dans le même repo (go.work) :

cd <GhostDrive-root>
go work use ./my-plugin

Étape 2 — Copier l'exemple echo

# Option A : depuis le template
cp -r plugins/sdk/go/my-plugin/ /path/to/my-plugin

# Option B : depuis le repo GhostDrive (si dans le même go.work)
cp -r plugins/sdk/go /path/to/my-plugin
cd my-plugin

Étape 3 — Implémenter StorageBackend

Ouvrez main.go et créez une struct qui satisfait plugins.StorageBackend :

package main

import (
    "context"
    "fmt"
    
    sdk "github.com/CCoupel/GhostDrive/plugins/sdk/go"
    "github.com/CCoupel/GhostDrive/plugins"
    goplugin "github.com/hashicorp/go-plugin"
)

// MyPlugin est votre implémentation.
// Le nom doit être en minuscules, sans espaces.
type MyPlugin struct {
    connected bool
    config    plugins.BackendConfig
}

// Name retourne l'identifiant du plugin.
// Cette valeur DOIT correspondre à BackendConfig.Type dans la config utilisateur.
func (p *MyPlugin) Name() string { return "myplugin" }

// Connect initialise le backend.
func (p *MyPlugin) Connect(cfg plugins.BackendConfig) error {
    // Valider les params obligatoires
    if cfg.Params["url"] == "" {
        return fmt.Errorf("myplugin: connect: url param is required")
    }
    p.config = cfg
    p.connected = true
    return nil
}

// ... implémenter toutes les autres méthodes de StorageBackend

Voici un template avec godoc minimales pour chaque méthode :

func (p *MyPlugin) Disconnect() error {
    p.connected = false
    return nil
}

func (p *MyPlugin) IsConnected() bool { return p.connected }

func (p *MyPlugin) Upload(ctx context.Context, local, remote string, progress plugins.ProgressCallback) error {
    if !p.connected {
        return fmt.Errorf("myplugin: upload: %w", plugins.ErrNotConnected)
    }
    // Implémenter le transfert
    return nil
}

// ... et les 11 autres méthodes ...

Pour un exemple complet, consultez plugins/sdk/go/echo/main.go.

Étape 4 — Configurer le serveur go-plugin

Votre main() doit servir votre implémentation via go-plugin :

func main() {
    // IMPORTANT : utiliser sdk.ServeConfig pour injecter la HandshakeConfig correcte
    goplugin.Serve(sdk.ServeConfig(&MyPlugin{}))
}

Ne pas copier HandshakeConfig — utiliser loader.HandshakeConfig depuis le SDK. C'est critique pour la compatibilité.

Étape 5 — Compiler pour Windows et Linux

Utilisez le Makefile fourni :

# Windows uniquement (AMD64)
make build
# → myplugin.exe  (Windows AMD64)

# Linux uniquement (AMD64)
make build-linux
# → myplugin      (Linux AMD64, extensionless, execute bit set)

# Les deux
make build-all

Ou manuellement :

# Windows
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -tags ignore -ldflags="-s -w" -o myplugin.exe .

# Linux
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -tags ignore -ldflags="-s -w" -o myplugin .

Important : le flag -tags ignore est requis pour que les binaires skip les dépendances spécifiques à GhostDrive.

Étape 6 — Installer dans GhostDrive

Copiez le binaire dans le répertoire des plugins de GhostDrive :

# Windows
copy myplugin.exe <AppDir>\plugins\myplugin.exe

# Linux — le loader détecte les exécutables sans extension
cp myplugin <AppDir>/plugins/myplugin
chmod +x <AppDir>/plugins/myplugin

<AppDir> est le répertoire contenant GhostDrive.exe (Windows) ou le binaire ghostdrive (Linux).

Étape 7 — Charger et tester

Le plugin est chargé à l'un de ces moments :

  • Au démarrage de GhostDrive : le loader scanne <AppDir>/plugins/ automatiquement
  • Via ReloadPlugins() : depuis la page Settings > Plugins

Votre plugin apparaît dans le sélecteur Add Backend sous le nom retourné par Name().


4. Conventions obligatoires

Stateless entre les instances

Le watchdog peut redémarrer votre plugin à tout moment (crash, timeout, etc.). Votre binaire ne doit pas conserver d'état persistant entre les redémarrages. Pas de :

  • Fichiers temporaires qui survivent la fin du processus
  • Locks de fichiers hors du contexte d'une session
  • Sockets Unix qui ne sont pas cleanupées à la fin
// ✓ Bon : chaque instance est indépendante
type MyPlugin struct {
    connected bool
    // config...
}

// ✗ Mauvais : état persistant
var globalCache = make(map[string][]byte) // ne survivra pas au redémarrage

Name() idempotent et sans I/O

Name() est appelé avant Connect() par le loader pour enregistrer le plugin. Il ne doit jamais :

  • Accéder au réseau
  • Lire/écrire des fichiers
  • Faire d'autres I/O
// ✓ Bon
func (p *MyPlugin) Name() string { return "myplugin" }

// ✗ Mauvais
func (p *MyPlugin) Name() string {
    // Ne pas faire ceci !
    return detectPluginName() // I/O, peut échouer
}

Pas de goroutines en fuite après Disconnect()

Quand Disconnect() est appelé, terminez proprement toutes les goroutines lancées par votre plugin. Sinon, le watchdog ne pourra pas tuer le subprocess.

type MyPlugin struct {
    connected bool
    cancel    context.CancelFunc // pour annoncer l'arrêt
}

func (p *MyPlugin) Connect(cfg plugins.BackendConfig) error {
    ctx, cancel := context.WithCancel(context.Background())
    p.cancel = cancel
    
    // Lancer les goroutines avec ce context
    go p.watcherLoop(ctx)
    return nil
}

func (p *MyPlugin) Disconnect() error {
    p.connected = false
    if p.cancel != nil {
        p.cancel() // ← arrête toutes les goroutines qui écoutent ctx
    }
    return nil
}

Autres conventions

  • Package : main (plugins dynamiques) ou plugins/<nom>/ (statiques)
  • Struct : MyPlugin ou le nom du plugin en CamelCase
  • Constructeur : les plugins dynamiques n'en ont pas (la factory ne s'applique pas)
  • Chemins : toujours slash-séparés (/) même sur Windows, relatifs à RemotePath

5. Transport gRPC

HandshakeConfig

Le handshake go-plugin assure que le client (GhostDrive) et le serveur (votre plugin) parlent la même langue. Vous ne devez pas copier HandshakeConfig ; utiliser celui du SDK :

// CORRECT
goplugin.Serve(sdk.ServeConfig(&MyPlugin{}))
// ↑ sdk.ServeConfig injecte automatiquement loader.HandshakeConfig

// INCORRECT
goplugin.Serve(&goplugin.ServeConfig{
    HandshakeConfig: goplugin.HandshakeConfig{
        ProtocolVersion: 1,
        MagicCookieKey: "GHOSTDRIVE_PLUGIN",
        MagicCookieValue: "storage.v1",
    },
    // ...
})
// ↑ Ne pas le redéfinir !

Mapping erreur Go ↔ gRPC

Les erreurs Go sentinelles sont round-trippées à travers le pont gRPC via un mécanisme de mapping :

Go → gRPC (côté serveur) :

plugins.ErrNotConnected ──┐
                           ├──→ mapBackendError(err) ──→ gRPC status error
plugins.ErrFileNotFound ──┘      + message d'erreur

gRPC → Go (côté client) :

gRPC status error ──→ mapGRPCError(status) ──→ plugins.ErrNotConnected
  + message          ou plugins.ErrFileNotFound

Pour que ce round-trip fonctionne, wrappez toujours vos erreurs avec les sentinelles :

// Le loader reconnaît et récupère la sentinelle
return fmt.Errorf("myplugin: stat %s: %w", path, plugins.ErrFileNotFound)

// Côté client, errors.Is fonctionne correctement
err := backend.Stat(ctx, "/missing")
if errors.Is(err, plugins.ErrFileNotFound) {
    // ✓ Condition satisfaite
}

Compatibilité polyglotte

Le proto est défini dans plugins/proto/storage.proto. Vous pouvez implémenter un plugin dans n'importe quel langage supportant gRPC (Python, Rust, C#, etc.) :

# Générer les stubs pour votre langage
protoc --python_out=. plugins/proto/storage.proto
# ou
protoc --rust_out=. plugins/proto/storage.proto

Cependant, vous devez toujours gérer le handshake go-plugin (magic cookie), qui est spécifique à HashiCorp go-plugin. Les implémentations non-Go doivent en émettre les signaux sur stdout/stderr.

Exemple (pseudo-code Rust) :

use std::io::Write;

fn main() {
    // Handshake go-plugin
    let handshake_response = serde_json::json!({
        "Protocol": "grpc",
        "ProtocolVersion": 1,
        "Addr": "[::1]:port",
        "MagicCookieKey": "GHOSTDRIVE_PLUGIN",
        "MagicCookieValue": "storage.v1",
    });
    println!("{}", serde_json::to_string(&handshake_response).unwrap());
    std::io::stdout().flush().unwrap();
    
    // Lancer le serveur gRPC sur le port
    // ...
}

6. Tests

ServeInProcess — démarrer un serveur gRPC in-memory

La fonction ServeInProcess(impl StorageBackend) (dans plugins/grpc/inprocess.go) démarre un serveur gRPC in-memory via bufconn et retourne un client GRPCBackend connecté à ce serveur :

import "github.com/CCoupel/GhostDrive/plugins/grpc"

func main() {
    // Créer une implémentation de StorageBackend
    backend := &MyPlugin{}
    
    // Démarrer le serveur in-process
    grpcBackend, cleanup, err := grpc.ServeInProcess(backend)
    if err != nil {
        log.Fatal(err)
    }
    defer cleanup() // Arrêter le serveur
    
    // Utiliser grpcBackend comme une instance normale
    err = grpcBackend.Connect(config)
    if err != nil {
        // ... gérer l'erreur
    }
}

Cas d'usage :

  • Tester la bridge gRPC sans spawner un subprocess
  • Valider la sérialisation/désérialisation des messages proto
  • Tests d'intégration dans la suite de tests GhostDrive

Pattern bufconn pour les tests unitaires gRPC

L'approche bufconn (in-process buffer connection) permet de tester votre bridge gRPC sans vraiment faire du networking. C'est le pattern utilisé dans plugins/grpc/client_test.go :

package grpc_test

import (
    "context"
    "net"
    "testing"
    
    "google.golang.org/grpc"
    "google.golang.org/grpc/test/bufconn"
    
    grpcbridge "github.com/CCoupel/GhostDrive/plugins/grpc"
    storagepb "github.com/CCoupel/GhostDrive/plugins/proto"
    "github.com/CCoupel/GhostDrive/plugins"
)

const bufSize = 1 << 20 // 1 MB

// Démarrer une paire serveur/client en mémoire
func newTestPair(t *testing.T, impl plugins.StorageBackend) (*grpcbridge.GRPCBackend, func()) {
    t.Helper()
    
    // Buffer listener
    lis := bufconn.Listen(bufSize)
    srv := grpc.NewServer()
    storagepb.RegisterStorageServiceServer(srv, &grpcbridge.GRPCBackendServer{Impl: impl})
    
    go func() { _ = srv.Serve(lis) }()
    
    // Client connecté au listener en mémoire
    conn, err := grpc.NewClient("passthrough://bufnet",
        grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
            return lis.DialContext(ctx)
        }),
    )
    require.NoError(t, err)
    
    backend := grpcbridge.NewGRPCBackend(conn)
    cleanup := func() {
        conn.Close()
        srv.GracefulStop()
        lis.Close()
    }
    return backend, cleanup
}

// Test exemple
func TestUpload(t *testing.T) {
    impl := &myMockBackend{}
    backend, cleanup := newTestPair(t, impl)
    defer cleanup()
    
    err := backend.Upload(context.Background(), "/local/file.txt", "/remote/file.txt", nil)
    assert.NoError(t, err)
}

Tests minimaux requis pour votre plugin

Test Description
TestConnect_Valid Connexion avec config valide, vérifier IsConnected() == true
TestConnect_MissingParam Params obligatoires manquants, retour erreur
TestConnect_BackendUnreachable Backend inaccessible, retour erreur descriptive
TestDisconnect_AllOpsReturnErrNotConnected Après Disconnect(), vérifier que toutes les ops retournent ErrNotConnected
TestUploadDownload_Roundtrip Upload → Download → contenu identique
TestList_Empty Répertoire vide → slice vide
TestList_Populated Répertoire peuplé → enfants corrects
TestList_NotFound Chemin inexistant → ErrFileNotFound
TestStat_Exists Fichier existant → métadonnées correctes
TestStat_NotFound Fichier inexistant → ErrFileNotFound
TestDelete_Exists Suppression d'un fichier existant
TestDelete_NotFound Suppression inexistant → ErrFileNotFound
TestCreateDir_New Créer un nouveau répertoire
TestCreateDir_Exists Répertoire existant → no-op (pas d'erreur)
TestMove_Rename Renommage simple oldPathnewPath
TestMove_Overwrite Overwrite si newPath existe
TestWatch_ContextCancelled Le canal se ferme quand le context est annulé
TestReadAt_Success Lecture partielle (v2.0+) — vérifier que les bytes correct sont retournés
TestReadAt_PartialFile Lecture near EOF — len(data) < length accepté
TestReadAt_NotConnected ReadAt sans connexion → ErrNotConnected
TestReadAt_FileNotFound ReadAt sur fichier inexistant → ErrFileNotFound (wrapped)
TestChunkSize_Returns ChunkSize() retourne la valeur documentée (0 ou backend-spécifique)

Lancez vos tests avec :

go test ./... -v -cover

Visez une couverture minimale de 70% sur votre plugin.


7. Checklist avant PR

  • go vet ./... — aucun warning
  • go test ./... -v -cover — couverture ≥ 70%
  • make build (Windows) produit un binaire .exe
  • make build-linux (Linux) produit un binaire sans extension
  • Binaire testé avec GRPCLoader — vérifié que le handshake réussit
  • Name() retourne une valeur immutable et distincte
  • ReadAt() + ChunkSize() implémentés (v2.0+) — tests couvrant les cas partiels, EOF, ErrNotConnected, ErrFileNotFound
  • ChunkSize() retourne une valeur documentée (0 pour défaut global, ou backend-spécifique)
  • Pas de goroutines en fuite après Disconnect()
  • Erreurs wrappées avec les sentinelles plugins.ErrNotConnected / plugins.ErrFileNotFound
  • Commentaires godoc sur chaque méthode (incluant ReadAt et ChunkSize)
  • contracts/backend-config.md — section du plugin avec les Params obligatoires
  • Branche : feat/myplugin ou équivalent

8. Exemple : Plugin WebDAV

Le plugin WebDAV (plugins/webdav/, v0.8.0+) est une implémentation réelle complète de StorageBackend à utiliser comme référence.

Structure

plugins/webdav/
├── webdav.go             # Implémentation StorageBackend
├── webdav_test.go        # 35 tests d'intégration
├── Makefile              # Build Windows/Linux
├── go.mod
└── ...

Caractéristiques

  • Auth : Basic Auth (username + password) ou Bearer Token (token)
  • TLS : paramètre tlsSkipVerify pour désactiver la vérification de certificat
  • Watch : polling PROPFIND avec intervalle configurable en millisecondes (pollInterval)
  • GetQuota : retourne gracieusement (-1, -1, nil) si le serveur WebDAV ne supporte pas la quota
  • ReadAt (v2.0+) : utilise HTTP Range header (RFC 7233) — 206 Partial Content pour range supportées, fallback 200 OK + slice pour serveurs sans support
  • ChunkSize (v2.0+) : retourne 0 (pas de granularité native — offset libre)
  • Compatibility : testée avec Synology, TrueNAS, Nextcloud

Exemple de Connect

func (w *WebDAVBackend) Connect(cfg plugins.BackendConfig) error {
    // Valider les params
    url := cfg.Params["url"]
    authType := cfg.Params["authType"] // "basic" ou "bearer"
    
    if url == "" {
        return fmt.Errorf("webdav: url param is required")
    }
    
    // Configurer le client WebDAV
    c := &webdav.HTTPClient{
        Transport: &http.Transport{
            TLSClientConfig: &tls.Config{
                InsecureSkipVerify: cfg.Params["tlsSkipVerify"] == "true",
            },
        },
    }
    
    // Ajouter l'authentification
    if authType == "basic" {
        c.Transport.(*http.Transport).MaxIdleConns = 10
        // Créer un client avec Basic Auth (via go-http-auth)
    }
    
    // Prober le backend — PROPFIND sur RemotePath
    w.client = c
    w.config = cfg
    
    // Vérifier la connectivité
    if _, err := w.List(context.Background(), cfg.RemotePath); err != nil {
        return fmt.Errorf("webdav: connect: probe failed: %w", err)
    }
    
    w.connected = true
    return nil
}

Tests

  • 35 tests unitaires : Connect/Disconnect, Upload/Download, Delete/Move, List/Stat, CreateDir, Watch, GetQuota
  • Serveur WebDAV in-memory (golang.org/x/net/webdav) pour les tests d'intégration
  • Coverage 76.8%
  • Aucune race condition détectée

Params WebDAV (contracts/backend-config.md)

| Param            | Type   | Obligatoire | Exemple                |
|------------------|--------|-------------|------------------------|
| url              | string | ✓           | `https://nas.local/dav` |
| authType         | string | ✓           | `basic` ou `bearer`    |
| username         | string | Si basic    | `admin`                |
| password         | string | Si basic    | `***`                  |
| token            | string | Si bearer   | `Bearer xyz...`        |
| tlsSkipVerify    | string |             | `"true"` (défaut: "")  |
| pollInterval     | string |             | `2000` ms (défaut: 5s) |

Consultez plugins/webdav/webdav.go pour une implémentation de référence complète.


8. Intégration Cloud Filter API (Windows v2.1+)

Vue d'ensemble

À partir de v2.1, GhostDrive supporte Files On-Demand via la Cloud Filter API Windows (CFAPI). Cette fonctionnalité permet aux utilisateurs de créer des placeholders légers pour les fichiers distants, qui sont hydratés à la demande.

Point clé : la Cloud Filter API est gérée entièrement par GhostDrive core (internal/cfapi/, internal/cache/). Vos plugins n'ont rien à faire de spécial — ils doivent simplement implémenter correctement ReadAt() et ChunkSize().

Comment vos plugins alimentent CFAPI

1. ReadAt() — Lectures par plage (Range Reads)

// ReadAt(ctx, remote, offset, length) doit retourner jusqu'à `length` octets
// à partir de l'offset `offset` dans le fichier distant.
func (p *MyPlugin) ReadAt(ctx context.Context, remote string, offset, length int64) ([]byte, error)

Comment CFAPI l'utilise :

  • Quand l'utilisateur accède un placeholder, CFAPI déclenche un callback FETCH_DATA
  • GhostDrive core appelle ReadAt(ctx, remote, 0, ChunkSize()) pour hydroter le premier chunk
  • Puis appelle ReadAt(ctx, remote, ChunkSize, ChunkSize) pour le suivant, etc.
  • Les chunks sont cachés localement via BoltDB (TTL configurable, défaut 24h)

Implémentation:

  • WebDAV : utilise HTTP Range header (RFC 7233) Range: bytes=offset-offset+length-1
  • MooseFS : positionne le file descriptor et lit
  • Autres : implémentez le support ou retournez une erreur NotImplemented (fallback : Download complet)

2. ChunkSize() — Granularité de lecture

// ChunkSize() retourne la taille naturelle de chunk pour ce backend.
// Retourner 0 = taille libre (CFAPI utilisera une taille par défaut ~4 MiB)
func (p *MyPlugin) ChunkSize() int64

Valeurs recommandées :

  • WebDAV : 0 (HTTP supporte n'importe quel offset/length)
  • MooseFS : 16384 ou 65536 (blocs MooseFS)
  • S3 : 5242880 (5 MiB min pour multipart, mais CFAPI ignorera si vous retournez 0)
  • Filesystem local : 0

Exemple : WebDAV avec ReadAt

import (
    "context"
    "fmt"
    "io"
    "net/http"
)

// ReadAt pour WebDAV
func (w *WebDAVBackend) ReadAt(ctx context.Context, remote string, offset, length int64) ([]byte, error) {
    if !w.connected {
        return nil, fmt.Errorf("webdav: %w", plugins.ErrNotConnected)
    }
    
    url := w.buildURL(remote)
    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return nil, fmt.Errorf("webdav: ReadAt: %w", err)
    }
    
    // Ajouter le header Range pour demander seulement la plage voulue
    req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", offset, offset+length-1))
    
    resp, err := w.client.Do(req)
    if err != nil {
        return nil, fmt.Errorf("webdav: ReadAt: %w", err)
    }
    defer resp.Body.Close()
    
    // 206 = Partial Content (range supporté)
    // 200 = OK (range non supporté — serveur retourne tout le fichier)
    if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
        return nil, fmt.Errorf("webdav: ReadAt: status %d", resp.StatusCode)
    }
    
    data, err := io.ReadAll(resp.Body)
    if err != nil {
        return nil, fmt.Errorf("webdav: ReadAt: read failed: %w", err)
    }
    
    return data, nil
}

// ChunkSize pour WebDAV (taille libre)
func (w *WebDAVBackend) ChunkSize() int64 {
    return 0  // WebDAV supporte n'importe quel offset/length
}

Limitations & Notes

  • Linux : CFAPI est Windows uniquement. Votre plugin compile sur Linux sans changement — GhostDrive utilise un fallback "sync complet" (aucune hydration progressive).
  • Cache : les chunks sont cachés par GhostDrive core, pas par le plugin. Implémentez ReadAt() de manière simple et sans cache interne.
  • Erreurs : retournez ErrFileNotFound si le fichier est supprimé pendant la lecture ; GhostDrive invalidera le placeholder.
  • Concurrence : ReadAt() peut être appelé de plusieurs goroutines en parallèle. Soyez thread-safe.

Notes

  • Quota non-supportée : si votre backend ne supporte pas la quota, retournez (-1, -1, nil).
  • Plugin Windows uniquement : votre binaire ne compile que pour Windows ? C'est OK, mais documentez-le. Les utilisateurs Linux verront une erreur de handshake au chargement (watchdog marquera le plugin comme "failed").
  • Threading : StorageBackend doit être thread-safe — les appels peuvent arriver de goroutines différentes. Utilisez un sync.RWMutex si vous tenez de l'état.

Ressources

  • Interface complète : plugins/plugin.go
  • Exemple référence : plugins/sdk/go/echo/main.go
  • Pattern test : plugins/grpc/client_test.go
  • Loader : plugins/loader/grpc_loader.go
  • SDK : plugins/sdk/go/