feat(storage): define FileStore interface and refactor to use it
Extract a FileStore interface from the concrete *storage.Storage MinIO wrapper so the API layer is storage-backend agnostic. - Define FileStore interface in internal/storage/interface.go - Add Exists method to MinIO Storage (via StatObject) - Add compile-time interface satisfaction check - Change Server.storage and ServerState.storage to FileStore interface - Update NewServer and NewServerState signatures - Add Backend and FilesystemConfig fields to StorageConfig - Add backend selection switch in main.go (minio/filesystem/unknown) - Update config.example.yaml with backend field The nil-interface pattern is preserved: when storage is unconfigured, store remains a true nil FileStore (not a typed nil pointer), so all existing if s.storage == nil checks continue to work correctly. Closes #126
This commit is contained in:
@@ -65,9 +65,11 @@ func main() {
|
|||||||
logger.Info().Msg("connected to database")
|
logger.Info().Msg("connected to database")
|
||||||
|
|
||||||
// Connect to storage (optional - may be externally managed)
|
// Connect to storage (optional - may be externally managed)
|
||||||
var store *storage.Storage
|
var store storage.FileStore
|
||||||
|
switch cfg.Storage.Backend {
|
||||||
|
case "minio", "":
|
||||||
if cfg.Storage.Endpoint != "" {
|
if cfg.Storage.Endpoint != "" {
|
||||||
store, err = storage.Connect(ctx, storage.Config{
|
s, connErr := storage.Connect(ctx, storage.Config{
|
||||||
Endpoint: cfg.Storage.Endpoint,
|
Endpoint: cfg.Storage.Endpoint,
|
||||||
AccessKey: cfg.Storage.AccessKey,
|
AccessKey: cfg.Storage.AccessKey,
|
||||||
SecretKey: cfg.Storage.SecretKey,
|
SecretKey: cfg.Storage.SecretKey,
|
||||||
@@ -75,15 +77,28 @@ func main() {
|
|||||||
UseSSL: cfg.Storage.UseSSL,
|
UseSSL: cfg.Storage.UseSSL,
|
||||||
Region: cfg.Storage.Region,
|
Region: cfg.Storage.Region,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if connErr != nil {
|
||||||
logger.Warn().Err(err).Msg("failed to connect to storage - file operations disabled")
|
logger.Warn().Err(connErr).Msg("failed to connect to storage - file operations disabled")
|
||||||
store = nil
|
|
||||||
} else {
|
} else {
|
||||||
|
store = s
|
||||||
logger.Info().Msg("connected to storage")
|
logger.Info().Msg("connected to storage")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.Info().Msg("storage not configured - file operations disabled")
|
logger.Info().Msg("storage not configured - file operations disabled")
|
||||||
}
|
}
|
||||||
|
case "filesystem":
|
||||||
|
if cfg.Storage.Filesystem.RootDir == "" {
|
||||||
|
logger.Fatal().Msg("storage.filesystem.root_dir is required when backend is \"filesystem\"")
|
||||||
|
}
|
||||||
|
s, fsErr := storage.NewFilesystemStore(cfg.Storage.Filesystem.RootDir)
|
||||||
|
if fsErr != nil {
|
||||||
|
logger.Fatal().Err(fsErr).Msg("failed to initialize filesystem storage")
|
||||||
|
}
|
||||||
|
store = s
|
||||||
|
logger.Info().Str("root", cfg.Storage.Filesystem.RootDir).Msg("connected to filesystem storage")
|
||||||
|
default:
|
||||||
|
logger.Fatal().Str("backend", cfg.Storage.Backend).Msg("unknown storage backend")
|
||||||
|
}
|
||||||
|
|
||||||
// Load schemas
|
// Load schemas
|
||||||
schemas, err := schema.LoadAll(cfg.Schemas.Directory)
|
schemas, err := schema.LoadAll(cfg.Schemas.Directory)
|
||||||
|
|||||||
@@ -17,12 +17,17 @@ database:
|
|||||||
max_connections: 10
|
max_connections: 10
|
||||||
|
|
||||||
storage:
|
storage:
|
||||||
|
backend: "minio" # "minio" (default) or "filesystem"
|
||||||
|
# MinIO/S3 settings (used when backend: "minio")
|
||||||
endpoint: "localhost:9000" # Use "minio:9000" for Docker Compose
|
endpoint: "localhost:9000" # Use "minio:9000" for Docker Compose
|
||||||
access_key: "" # Use SILO_MINIO_ACCESS_KEY env var
|
access_key: "" # Use SILO_MINIO_ACCESS_KEY env var
|
||||||
secret_key: "" # Use SILO_MINIO_SECRET_KEY env var
|
secret_key: "" # Use SILO_MINIO_SECRET_KEY env var
|
||||||
bucket: "silo-files"
|
bucket: "silo-files"
|
||||||
use_ssl: true # Use false for Docker Compose (internal network)
|
use_ssl: true # Use false for Docker Compose (internal network)
|
||||||
region: "us-east-1"
|
region: "us-east-1"
|
||||||
|
# Filesystem settings (used when backend: "filesystem")
|
||||||
|
# filesystem:
|
||||||
|
# root_dir: "/var/lib/silo/objects"
|
||||||
|
|
||||||
schemas:
|
schemas:
|
||||||
# Directory containing YAML schema files
|
# Directory containing YAML schema files
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ type Server struct {
|
|||||||
schemas map[string]*schema.Schema
|
schemas map[string]*schema.Schema
|
||||||
schemasDir string
|
schemasDir string
|
||||||
partgen *partnum.Generator
|
partgen *partnum.Generator
|
||||||
storage *storage.Storage
|
storage storage.FileStore
|
||||||
auth *auth.Service
|
auth *auth.Service
|
||||||
sessions *scs.SessionManager
|
sessions *scs.SessionManager
|
||||||
oidc *auth.OIDCBackend
|
oidc *auth.OIDCBackend
|
||||||
@@ -61,7 +61,7 @@ func NewServer(
|
|||||||
database *db.DB,
|
database *db.DB,
|
||||||
schemas map[string]*schema.Schema,
|
schemas map[string]*schema.Schema,
|
||||||
schemasDir string,
|
schemasDir string,
|
||||||
store *storage.Storage,
|
store storage.FileStore,
|
||||||
authService *auth.Service,
|
authService *auth.Service,
|
||||||
sessionManager *scs.SessionManager,
|
sessionManager *scs.SessionManager,
|
||||||
oidcBackend *auth.OIDCBackend,
|
oidcBackend *auth.OIDCBackend,
|
||||||
|
|||||||
@@ -26,13 +26,13 @@ type ServerState struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
readOnly bool
|
readOnly bool
|
||||||
storageOK bool
|
storageOK bool
|
||||||
storage *storage.Storage
|
storage storage.FileStore
|
||||||
broker *Broker
|
broker *Broker
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServerState creates a new server state tracker.
|
// NewServerState creates a new server state tracker.
|
||||||
func NewServerState(logger zerolog.Logger, store *storage.Storage, broker *Broker) *ServerState {
|
func NewServerState(logger zerolog.Logger, store storage.FileStore, broker *Broker) *ServerState {
|
||||||
return &ServerState{
|
return &ServerState{
|
||||||
logger: logger.With().Str("component", "server-state").Logger(),
|
logger: logger.With().Str("component", "server-state").Logger(),
|
||||||
storageOK: store != nil, // assume healthy if configured
|
storageOK: store != nil, // assume healthy if configured
|
||||||
|
|||||||
@@ -109,14 +109,21 @@ type DatabaseConfig struct {
|
|||||||
MaxConnections int `yaml:"max_connections"`
|
MaxConnections int `yaml:"max_connections"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StorageConfig holds MinIO connection settings.
|
// StorageConfig holds object storage settings.
|
||||||
type StorageConfig struct {
|
type StorageConfig struct {
|
||||||
|
Backend string `yaml:"backend"` // "minio" (default) or "filesystem"
|
||||||
Endpoint string `yaml:"endpoint"`
|
Endpoint string `yaml:"endpoint"`
|
||||||
AccessKey string `yaml:"access_key"`
|
AccessKey string `yaml:"access_key"`
|
||||||
SecretKey string `yaml:"secret_key"`
|
SecretKey string `yaml:"secret_key"`
|
||||||
Bucket string `yaml:"bucket"`
|
Bucket string `yaml:"bucket"`
|
||||||
UseSSL bool `yaml:"use_ssl"`
|
UseSSL bool `yaml:"use_ssl"`
|
||||||
Region string `yaml:"region"`
|
Region string `yaml:"region"`
|
||||||
|
Filesystem FilesystemConfig `yaml:"filesystem"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilesystemConfig holds local filesystem storage settings.
|
||||||
|
type FilesystemConfig struct {
|
||||||
|
RootDir string `yaml:"root_dir"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SchemasConfig holds schema loading settings.
|
// SchemasConfig holds schema loading settings.
|
||||||
|
|||||||
21
internal/storage/interface.go
Normal file
21
internal/storage/interface.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// Package storage defines the FileStore interface and backend implementations.
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileStore is the interface for file storage backends.
|
||||||
|
type FileStore interface {
|
||||||
|
Put(ctx context.Context, key string, reader io.Reader, size int64, contentType string) (*PutResult, error)
|
||||||
|
Get(ctx context.Context, key string) (io.ReadCloser, error)
|
||||||
|
GetVersion(ctx context.Context, key string, versionID string) (io.ReadCloser, error)
|
||||||
|
Delete(ctx context.Context, key string) error
|
||||||
|
Exists(ctx context.Context, key string) (bool, error)
|
||||||
|
Copy(ctx context.Context, srcKey, dstKey string) error
|
||||||
|
PresignPut(ctx context.Context, key string, expiry time.Duration) (*url.URL, error)
|
||||||
|
Ping(ctx context.Context) error
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package storage provides MinIO file storage operations.
|
|
||||||
package storage
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -22,6 +21,9 @@ type Config struct {
|
|||||||
Region string
|
Region string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compile-time check: *Storage implements FileStore.
|
||||||
|
var _ FileStore = (*Storage)(nil)
|
||||||
|
|
||||||
// Storage wraps MinIO client operations.
|
// Storage wraps MinIO client operations.
|
||||||
type Storage struct {
|
type Storage struct {
|
||||||
client *minio.Client
|
client *minio.Client
|
||||||
@@ -112,6 +114,19 @@ func (s *Storage) Delete(ctx context.Context, key string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exists checks if an object exists in storage.
|
||||||
|
func (s *Storage) Exists(ctx context.Context, key string) (bool, error) {
|
||||||
|
_, err := s.client.StatObject(ctx, s.bucket, key, minio.StatObjectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
resp := minio.ToErrorResponse(err)
|
||||||
|
if resp.Code == "NoSuchKey" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("checking object existence: %w", err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Ping checks if the storage backend is reachable by verifying the bucket exists.
|
// Ping checks if the storage backend is reachable by verifying the bucket exists.
|
||||||
func (s *Storage) Ping(ctx context.Context) error {
|
func (s *Storage) Ping(ctx context.Context) error {
|
||||||
_, err := s.client.BucketExists(ctx, s.bucket)
|
_, err := s.client.BucketExists(ctx, s.bucket)
|
||||||
|
|||||||
Reference in New Issue
Block a user