Version : v0.7.0 Audience : développeurs Go (humains et agents Claude Code) Temps estimé : 2-4 h pour un backend simple (filesystem, HTTP)
GhostDrive supporte deux modes de plugin :
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.
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.
┌─────────────────────────┐
│ 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)
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()
Source : plugins/plugin.go
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
}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
}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)
}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| 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éthode | Signature | Contrat |
|---|---|---|
Name() |
Name() string |
Retourne l'identifiant du plugin en minuscules (ex: "webdav", "echo"). Immutable ; peut être appelé avant Connect(). |
| 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. |
| 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à. |
| 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. |
| 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. |
| 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é. |
| 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.
| 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. |
É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
)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)
}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
}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)",
},
},
}
}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-pluginOption B — Dans le même repo (go.work) :
cd <GhostDrive-root>
go work use ./my-plugin# 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-pluginOuvrez 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 StorageBackendVoici 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.
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é.
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-allOu 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.
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/mypluginOù <AppDir> est le répertoire contenant GhostDrive.exe (Windows) ou le binaire ghostdrive (Linux).
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().
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émarrageName() 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
}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
}- Package :
main(plugins dynamiques) ouplugins/<nom>/(statiques) - Struct :
MyPluginou 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
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 !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
}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.protoCependant, 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
// ...
}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
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)
}| 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 oldPath → newPath |
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 -coverVisez une couverture minimale de 70% sur votre plugin.
-
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
ReadAtetChunkSize) -
contracts/backend-config.md— section du plugin avec les Params obligatoires - Branche :
feat/mypluginou équivalent
Le plugin WebDAV (plugins/webdav/, v0.8.0+) est une implémentation réelle complète de StorageBackend à utiliser comme référence.
plugins/webdav/
├── webdav.go # Implémentation StorageBackend
├── webdav_test.go # 35 tests d'intégration
├── Makefile # Build Windows/Linux
├── go.mod
└── ...
- Auth : Basic Auth (
username+password) ou Bearer Token (token) - TLS : paramètre
tlsSkipVerifypour 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 Contentpour range supportées, fallback200 OK+ slice pour serveurs sans support - ChunkSize (v2.0+) : retourne
0(pas de granularité native — offset libre) - Compatibility : testée avec Synology, TrueNAS, Nextcloud
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
}- 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
| 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.
À 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().
// 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)
// 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() int64Valeurs recommandées :
- WebDAV :
0(HTTP supporte n'importe quel offset/length) - MooseFS :
16384ou65536(blocs MooseFS) - S3 :
5242880(5 MiB min pour multipart, mais CFAPI ignorera si vous retournez0) - Filesystem local :
0
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
}- 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
ErrFileNotFoundsi 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.
- 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 :
StorageBackenddoit être thread-safe — les appels peuvent arriver de goroutines différentes. Utilisez unsync.RWMutexsi vous tenez de l'état.
- 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/