Changeset 0.12.0 (#63)
This commit is contained in:
78
server/storage/init.go
Normal file
78
server/storage/init.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Init creates an ObjectStore based on the configuration.
|
||||
//
|
||||
// Auto-detection when backend is empty:
|
||||
// - If storagePath is writable → PVC (implicit)
|
||||
// - If storagePath is missing/unwritable → nil (storage disabled)
|
||||
//
|
||||
// Explicit backends:
|
||||
// - "pvc" → PVC, fail if path is not writable
|
||||
// - "s3" → S3-compatible (MinIO, Ceph RGW, AWS), fail if not reachable
|
||||
func Init(backend, storagePath string, s3Cfg *S3Config) (ObjectStore, error) {
|
||||
switch backend {
|
||||
case "pvc":
|
||||
// Explicit PVC — fail if not writable
|
||||
s, err := NewPVC(storagePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: PVC backend at %q: %w", storagePath, err)
|
||||
}
|
||||
log.Printf(" 📁 Storage: PVC at %s", storagePath)
|
||||
return s, nil
|
||||
|
||||
case "s3":
|
||||
if s3Cfg == nil {
|
||||
return nil, fmt.Errorf("storage: S3 backend requires S3_BUCKET and credentials")
|
||||
}
|
||||
s, err := NewS3(*s3Cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: S3 backend: %w", err)
|
||||
}
|
||||
endpoint := s3Cfg.Endpoint
|
||||
if endpoint == "" {
|
||||
endpoint = "AWS"
|
||||
}
|
||||
log.Printf(" 📁 Storage: S3 at %s/%s (prefix=%q)", endpoint, s3Cfg.Bucket, s3Cfg.Prefix)
|
||||
return s, nil
|
||||
|
||||
case "":
|
||||
// Auto-detect: try PVC if path exists or is creatable
|
||||
s, err := NewPVC(storagePath)
|
||||
if err != nil {
|
||||
// Path not writable — storage disabled, not an error
|
||||
log.Printf(" 📁 Storage: disabled (path %s not writable)", storagePath)
|
||||
return nil, nil
|
||||
}
|
||||
log.Printf(" 📁 Storage: PVC at %s (auto-detected)", storagePath)
|
||||
return s, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("storage: unknown backend %q (expected pvc or s3)", backend)
|
||||
}
|
||||
}
|
||||
|
||||
// IsPathWritable checks if a directory path exists and is writable.
|
||||
// Used by auto-detection. Does not create the directory.
|
||||
func IsPathWritable(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try writing a probe file
|
||||
probe := path + "/.writable-probe"
|
||||
if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil {
|
||||
return false
|
||||
}
|
||||
os.Remove(probe)
|
||||
return true
|
||||
}
|
||||
278
server/storage/pvc.go
Normal file
278
server/storage/pvc.go
Normal file
@@ -0,0 +1,278 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ── PVC Store ──────────────────────────────
|
||||
|
||||
// PVCStore implements ObjectStore using the local filesystem.
|
||||
// Used with Kubernetes PersistentVolumeClaims (CephFS, NFS, local PV)
|
||||
// or plain Docker volume mounts.
|
||||
type PVCStore struct {
|
||||
basePath string
|
||||
|
||||
// Content-type cache: key → content type.
|
||||
// Populated on Put, used by Get. PVC doesn't store metadata
|
||||
// alongside files, so we keep MIME types in memory. On restart,
|
||||
// Get falls back to "application/octet-stream" — the DB has the
|
||||
// authoritative content_type for serving to clients.
|
||||
mu sync.RWMutex
|
||||
mimeMap map[string]string
|
||||
}
|
||||
|
||||
// NewPVC creates a PVCStore rooted at basePath.
|
||||
// Returns an error if the path does not exist or is not writable.
|
||||
func NewPVC(basePath string) (*PVCStore, error) {
|
||||
// Ensure base path exists
|
||||
if err := os.MkdirAll(basePath, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("storage/pvc: cannot create base path %q: %w", basePath, err)
|
||||
}
|
||||
|
||||
// Create well-known subdirectories
|
||||
for _, sub := range []string{"attachments", "processing"} {
|
||||
if err := os.MkdirAll(filepath.Join(basePath, sub), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("storage/pvc: cannot create %s dir: %w", sub, err)
|
||||
}
|
||||
}
|
||||
|
||||
s := &PVCStore{
|
||||
basePath: basePath,
|
||||
mimeMap: make(map[string]string),
|
||||
}
|
||||
|
||||
// Validate writability
|
||||
if err := s.Healthy(context.Background()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *PVCStore) Backend() string { return "pvc" }
|
||||
|
||||
// Put writes data to the filesystem at the resolved key path.
|
||||
func (s *PVCStore) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error {
|
||||
if err := validateKey(key); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
absPath := s.resolve(key)
|
||||
|
||||
// Ensure parent directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
|
||||
return fmt.Errorf("storage/pvc: mkdir for %q: %w", key, err)
|
||||
}
|
||||
|
||||
// Write to temp file first, then rename (atomic on same filesystem)
|
||||
tmp, err := os.CreateTemp(filepath.Dir(absPath), ".upload-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage/pvc: create temp for %q: %w", key, err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() {
|
||||
tmp.Close()
|
||||
// Clean up temp file on failure
|
||||
if err != nil {
|
||||
os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
|
||||
written, err := io.Copy(tmp, r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage/pvc: write %q: %w", key, err)
|
||||
}
|
||||
if err = tmp.Close(); err != nil {
|
||||
return fmt.Errorf("storage/pvc: close %q: %w", key, err)
|
||||
}
|
||||
|
||||
// Verify size if provided (0 means unknown)
|
||||
if size > 0 && written != size {
|
||||
os.Remove(tmpName)
|
||||
return fmt.Errorf("storage/pvc: size mismatch for %q: expected %d, got %d", key, size, written)
|
||||
}
|
||||
|
||||
// Atomic rename
|
||||
if err = os.Rename(tmpName, absPath); err != nil {
|
||||
return fmt.Errorf("storage/pvc: rename %q: %w", key, err)
|
||||
}
|
||||
|
||||
// Cache content type
|
||||
if contentType != "" {
|
||||
s.mu.Lock()
|
||||
s.mimeMap[key] = contentType
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns a reader for the file at key.
|
||||
func (s *PVCStore) Get(ctx context.Context, key string) (io.ReadCloser, int64, string, error) {
|
||||
if err := validateKey(key); err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
|
||||
absPath := s.resolve(key)
|
||||
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, 0, "", ErrNotFound
|
||||
}
|
||||
return nil, 0, "", fmt.Errorf("storage/pvc: stat %q: %w", key, err)
|
||||
}
|
||||
|
||||
f, err := os.Open(absPath)
|
||||
if err != nil {
|
||||
return nil, 0, "", fmt.Errorf("storage/pvc: open %q: %w", key, err)
|
||||
}
|
||||
|
||||
// Look up cached content type
|
||||
s.mu.RLock()
|
||||
ct := s.mimeMap[key]
|
||||
s.mu.RUnlock()
|
||||
if ct == "" {
|
||||
ct = "application/octet-stream"
|
||||
}
|
||||
|
||||
return f, info.Size(), ct, nil
|
||||
}
|
||||
|
||||
// Delete removes the file at key.
|
||||
func (s *PVCStore) Delete(ctx context.Context, key string) error {
|
||||
if err := validateKey(key); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
absPath := s.resolve(key)
|
||||
|
||||
err := os.Remove(absPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("storage/pvc: delete %q: %w", key, err)
|
||||
}
|
||||
|
||||
// Clean up cache
|
||||
s.mu.Lock()
|
||||
delete(s.mimeMap, key)
|
||||
s.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePrefix removes all files and directories under the given prefix.
|
||||
func (s *PVCStore) DeletePrefix(ctx context.Context, prefix string) error {
|
||||
if err := validateKey(prefix); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
absPath := s.resolve(prefix)
|
||||
|
||||
// Check if the path exists
|
||||
if _, err := os.Stat(absPath); os.IsNotExist(err) {
|
||||
return nil // Nothing to delete
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(absPath); err != nil {
|
||||
return fmt.Errorf("storage/pvc: delete prefix %q: %w", prefix, err)
|
||||
}
|
||||
|
||||
// Clean up cache entries with this prefix
|
||||
s.mu.Lock()
|
||||
for k := range s.mimeMap {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
delete(s.mimeMap, k)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exists checks if a file exists at key.
|
||||
func (s *PVCStore) Exists(ctx context.Context, key string) (bool, error) {
|
||||
if err := validateKey(key); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
_, err := os.Stat(s.resolve(key))
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("storage/pvc: exists %q: %w", key, err)
|
||||
}
|
||||
|
||||
// Healthy verifies the storage path is writable.
|
||||
func (s *PVCStore) Healthy(ctx context.Context) error {
|
||||
probe := filepath.Join(s.basePath, ".health-probe")
|
||||
if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil {
|
||||
return fmt.Errorf("storage/pvc: not writable: %w", err)
|
||||
}
|
||||
os.Remove(probe)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stats returns file count and total size under the base path.
|
||||
func (s *PVCStore) Stats(ctx context.Context) (*StorageStats, error) {
|
||||
stats := &StorageStats{
|
||||
Backend: "pvc",
|
||||
Path: s.basePath,
|
||||
Configured: true,
|
||||
}
|
||||
|
||||
// Check health
|
||||
if err := s.Healthy(ctx); err != nil {
|
||||
stats.Healthy = false
|
||||
return stats, nil
|
||||
}
|
||||
stats.Healthy = true
|
||||
|
||||
// Walk attachments directory for counts
|
||||
attDir := filepath.Join(s.basePath, "attachments")
|
||||
err := filepath.Walk(attDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // skip errors (e.g. permission denied)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
stats.TotalFiles++
|
||||
stats.TotalBytes += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return stats, fmt.Errorf("storage/pvc: stats walk: %w", err)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────
|
||||
|
||||
// resolve converts a storage key to an absolute filesystem path.
|
||||
func (s *PVCStore) resolve(key string) string {
|
||||
return filepath.Join(s.basePath, filepath.FromSlash(key))
|
||||
}
|
||||
|
||||
// validateKey ensures the key is safe (no path traversal, no absolute paths).
|
||||
func validateKey(key string) error {
|
||||
if key == "" {
|
||||
return fmt.Errorf("storage: empty key")
|
||||
}
|
||||
if strings.HasPrefix(key, "/") || strings.HasPrefix(key, "\\") {
|
||||
return fmt.Errorf("storage: absolute key not allowed: %q", key)
|
||||
}
|
||||
cleaned := filepath.Clean(key)
|
||||
if strings.Contains(cleaned, "..") {
|
||||
return fmt.Errorf("storage: path traversal not allowed: %q", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
295
server/storage/pvc_test.go
Normal file
295
server/storage/pvc_test.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func tempStore(t *testing.T) *PVCStore {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
s, err := NewPVC(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPVC(%q): %v", dir, err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestPVC_PutGetRoundTrip(t *testing.T) {
|
||||
s := tempStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
data := []byte("hello, storage world")
|
||||
key := "attachments/channel-1/att-1_test.txt"
|
||||
|
||||
// Put
|
||||
err := s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
|
||||
if err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
|
||||
// Get
|
||||
rc, size, ct, err := s.Get(ctx, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
if size != int64(len(data)) {
|
||||
t.Errorf("size = %d, want %d", size, len(data))
|
||||
}
|
||||
if ct != "text/plain" {
|
||||
t.Errorf("content_type = %q, want %q", ct, "text/plain")
|
||||
}
|
||||
|
||||
got, _ := io.ReadAll(rc)
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Errorf("data = %q, want %q", got, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVC_PutAtomicWrite(t *testing.T) {
|
||||
s := tempStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
key := "attachments/ch/file.bin"
|
||||
data := []byte("atomic test data")
|
||||
|
||||
err := s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "application/octet-stream")
|
||||
if err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
|
||||
// File should exist at final path, no temp files left
|
||||
absPath := filepath.Join(s.basePath, "attachments", "ch")
|
||||
entries, _ := os.ReadDir(absPath)
|
||||
for _, e := range entries {
|
||||
if e.Name() != "file.bin" {
|
||||
t.Errorf("unexpected file in dir: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVC_PutSizeMismatch(t *testing.T) {
|
||||
s := tempStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
data := []byte("short")
|
||||
err := s.Put(ctx, "attachments/ch/f.txt", bytes.NewReader(data), 999, "text/plain")
|
||||
if err == nil {
|
||||
t.Fatal("expected size mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVC_GetNotFound(t *testing.T) {
|
||||
s := tempStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, _, _, err := s.Get(ctx, "attachments/nonexistent/file.txt")
|
||||
if err != ErrNotFound {
|
||||
t.Errorf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVC_Delete(t *testing.T) {
|
||||
s := tempStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
key := "attachments/ch/delete-me.txt"
|
||||
_ = s.Put(ctx, key, bytes.NewReader([]byte("bye")), 3, "text/plain")
|
||||
|
||||
err := s.Delete(ctx, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
|
||||
exists, _ := s.Exists(ctx, key)
|
||||
if exists {
|
||||
t.Error("file still exists after Delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVC_DeleteIdempotent(t *testing.T) {
|
||||
s := tempStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Delete a file that doesn't exist — should not error
|
||||
err := s.Delete(ctx, "attachments/no-such/file.txt")
|
||||
if err != nil {
|
||||
t.Errorf("Delete nonexistent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVC_DeletePrefix(t *testing.T) {
|
||||
s := tempStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create several files under a channel prefix
|
||||
prefix := "attachments/channel-abc/"
|
||||
for _, name := range []string{"a.txt", "b.png", "c.pdf"} {
|
||||
key := prefix + name
|
||||
_ = s.Put(ctx, key, bytes.NewReader([]byte("x")), 1, "text/plain")
|
||||
}
|
||||
|
||||
// Also create a file in a different channel
|
||||
other := "attachments/channel-other/keep.txt"
|
||||
_ = s.Put(ctx, other, bytes.NewReader([]byte("y")), 1, "text/plain")
|
||||
|
||||
// Delete the channel prefix
|
||||
err := s.DeletePrefix(ctx, "attachments/channel-abc")
|
||||
if err != nil {
|
||||
t.Fatalf("DeletePrefix: %v", err)
|
||||
}
|
||||
|
||||
// Verify channel files are gone
|
||||
for _, name := range []string{"a.txt", "b.png", "c.pdf"} {
|
||||
exists, _ := s.Exists(ctx, prefix+name)
|
||||
if exists {
|
||||
t.Errorf("%s still exists after DeletePrefix", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify other channel is untouched
|
||||
exists, _ := s.Exists(ctx, other)
|
||||
if !exists {
|
||||
t.Error("other channel file was incorrectly deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVC_DeletePrefixNonexistent(t *testing.T) {
|
||||
s := tempStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Should not error
|
||||
err := s.DeletePrefix(ctx, "attachments/does-not-exist/")
|
||||
if err != nil {
|
||||
t.Errorf("DeletePrefix nonexistent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVC_Exists(t *testing.T) {
|
||||
s := tempStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
key := "attachments/ch/exists.txt"
|
||||
_ = s.Put(ctx, key, bytes.NewReader([]byte("hi")), 2, "text/plain")
|
||||
|
||||
exists, err := s.Exists(ctx, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Exists: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Error("Exists returned false for existing file")
|
||||
}
|
||||
|
||||
exists, err = s.Exists(ctx, "attachments/ch/nope.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Exists (missing): %v", err)
|
||||
}
|
||||
if exists {
|
||||
t.Error("Exists returned true for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVC_Healthy(t *testing.T) {
|
||||
s := tempStore(t)
|
||||
if err := s.Healthy(context.Background()); err != nil {
|
||||
t.Errorf("Healthy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVC_Stats(t *testing.T) {
|
||||
s := tempStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Empty store
|
||||
stats, err := s.Stats(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Stats: %v", err)
|
||||
}
|
||||
if stats.TotalFiles != 0 || stats.TotalBytes != 0 {
|
||||
t.Errorf("empty store: files=%d bytes=%d", stats.TotalFiles, stats.TotalBytes)
|
||||
}
|
||||
if !stats.Healthy || !stats.Configured {
|
||||
t.Error("expected healthy + configured")
|
||||
}
|
||||
|
||||
// Add some files
|
||||
for i := 0; i < 3; i++ {
|
||||
key := filepath.Join("attachments", "ch", string(rune('a'+i))+".txt")
|
||||
data := bytes.Repeat([]byte("x"), 100*(i+1))
|
||||
_ = s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
|
||||
}
|
||||
|
||||
stats, err = s.Stats(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Stats: %v", err)
|
||||
}
|
||||
if stats.TotalFiles != 3 {
|
||||
t.Errorf("files = %d, want 3", stats.TotalFiles)
|
||||
}
|
||||
if stats.TotalBytes != 600 { // 100 + 200 + 300
|
||||
t.Errorf("bytes = %d, want 600", stats.TotalBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVC_PathTraversal(t *testing.T) {
|
||||
s := tempStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
bad := []string{
|
||||
"../etc/passwd",
|
||||
"attachments/../../etc/shadow",
|
||||
"/absolute/path",
|
||||
"",
|
||||
}
|
||||
|
||||
for _, key := range bad {
|
||||
err := s.Put(ctx, key, bytes.NewReader([]byte("x")), 1, "text/plain")
|
||||
if err == nil {
|
||||
t.Errorf("Put(%q) should have failed", key)
|
||||
}
|
||||
|
||||
_, _, _, err = s.Get(ctx, key)
|
||||
if err == nil {
|
||||
t.Errorf("Get(%q) should have failed", key)
|
||||
}
|
||||
|
||||
err = s.Delete(ctx, key)
|
||||
if err == nil {
|
||||
t.Errorf("Delete(%q) should have failed", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVC_SubdirCreation(t *testing.T) {
|
||||
s := tempStore(t)
|
||||
|
||||
// Verify well-known subdirs were created
|
||||
for _, sub := range []string{"attachments", "processing"} {
|
||||
info, err := os.Stat(filepath.Join(s.basePath, sub))
|
||||
if err != nil {
|
||||
t.Errorf("subdir %q not created: %v", sub, err)
|
||||
continue
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Errorf("%q is not a directory", sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPVC_InvalidPath(t *testing.T) {
|
||||
// Path that can't be created (nested under a file)
|
||||
tmp := t.TempDir()
|
||||
filePath := filepath.Join(tmp, "blocker")
|
||||
os.WriteFile(filePath, []byte("x"), 0o644)
|
||||
|
||||
_, err := NewPVC(filepath.Join(filePath, "subdir"))
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid path")
|
||||
}
|
||||
}
|
||||
312
server/storage/s3.go
Normal file
312
server/storage/s3.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
// ── S3 Store ──────────────────────────────
|
||||
|
||||
// S3Config holds all configuration for the S3 backend.
|
||||
type S3Config struct {
|
||||
Endpoint string // e.g. "minio.example.com:9000" or "s3.amazonaws.com"
|
||||
Bucket string // bucket name
|
||||
Region string // AWS region (default "us-east-1")
|
||||
AccessKey string // access key ID
|
||||
SecretKey string // secret access key
|
||||
Prefix string // optional key prefix (e.g. "switchboard/")
|
||||
ForcePathStyle bool // path-style URLs (required for MinIO, most self-hosted)
|
||||
UseSSL bool // use HTTPS (derived from endpoint scheme)
|
||||
}
|
||||
|
||||
// S3Store implements ObjectStore using any S3-compatible API.
|
||||
// Tested with: MinIO, Ceph RGW, AWS S3.
|
||||
type S3Store struct {
|
||||
client *minio.Client
|
||||
bucket string
|
||||
prefix string // prepended to all keys (e.g. "switchboard/")
|
||||
endpoint string // original endpoint for display in Stats
|
||||
}
|
||||
|
||||
// NewS3 creates an S3Store connected to the given endpoint.
|
||||
// Returns an error if the bucket does not exist or is not accessible.
|
||||
func NewS3(cfg S3Config) (*S3Store, error) {
|
||||
if cfg.Bucket == "" {
|
||||
return nil, fmt.Errorf("storage/s3: bucket name is required")
|
||||
}
|
||||
if cfg.AccessKey == "" || cfg.SecretKey == "" {
|
||||
return nil, fmt.Errorf("storage/s3: access key and secret key are required")
|
||||
}
|
||||
if cfg.Region == "" {
|
||||
cfg.Region = "us-east-1"
|
||||
}
|
||||
|
||||
// Parse endpoint: strip scheme if present, detect SSL
|
||||
endpoint := cfg.Endpoint
|
||||
useSSL := cfg.UseSSL
|
||||
if strings.HasPrefix(endpoint, "https://") {
|
||||
endpoint = strings.TrimPrefix(endpoint, "https://")
|
||||
useSSL = true
|
||||
} else if strings.HasPrefix(endpoint, "http://") {
|
||||
endpoint = strings.TrimPrefix(endpoint, "http://")
|
||||
useSSL = false
|
||||
}
|
||||
// Default to SSL for AWS endpoints
|
||||
if endpoint == "" || strings.Contains(endpoint, "amazonaws.com") {
|
||||
useSSL = true
|
||||
}
|
||||
|
||||
opts := &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
||||
Secure: useSSL,
|
||||
Region: cfg.Region,
|
||||
}
|
||||
|
||||
// BucketLookup controls path-style vs virtual-hosted-style URLs.
|
||||
// MinIO, Ceph, and most self-hosted S3 require path-style.
|
||||
if cfg.ForcePathStyle {
|
||||
opts.BucketLookup = minio.BucketLookupPath
|
||||
} else {
|
||||
opts.BucketLookup = minio.BucketLookupAuto
|
||||
}
|
||||
|
||||
client, err := minio.New(endpoint, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage/s3: client init: %w", err)
|
||||
}
|
||||
|
||||
// Normalize prefix: ensure trailing slash if non-empty, no leading slash
|
||||
prefix := strings.TrimPrefix(cfg.Prefix, "/")
|
||||
if prefix != "" && !strings.HasSuffix(prefix, "/") {
|
||||
prefix += "/"
|
||||
}
|
||||
|
||||
s := &S3Store{
|
||||
client: client,
|
||||
bucket: cfg.Bucket,
|
||||
prefix: prefix,
|
||||
endpoint: endpoint,
|
||||
}
|
||||
|
||||
// Validate: check bucket exists and is accessible
|
||||
ctx := context.Background()
|
||||
exists, err := client.BucketExists(ctx, cfg.Bucket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage/s3: cannot reach endpoint: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("storage/s3: bucket %q does not exist", cfg.Bucket)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *S3Store) Backend() string { return "s3" }
|
||||
|
||||
// Put writes data to S3 at the given key.
|
||||
func (s *S3Store) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error {
|
||||
if err := validateKey(key); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
opts := minio.PutObjectOptions{
|
||||
ContentType: contentType,
|
||||
}
|
||||
|
||||
// If size is unknown (0), we need to buffer or use -1.
|
||||
// minio-go handles -1 with multipart upload.
|
||||
uploadSize := size
|
||||
if uploadSize == 0 {
|
||||
uploadSize = -1
|
||||
}
|
||||
|
||||
_, err := s.client.PutObject(ctx, s.bucket, s.fullKey(key), r, uploadSize, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage/s3: put %q: %w", key, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns a reader for the object at key.
|
||||
func (s *S3Store) Get(ctx context.Context, key string) (io.ReadCloser, int64, string, error) {
|
||||
if err := validateKey(key); err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
|
||||
obj, err := s.client.GetObject(ctx, s.bucket, s.fullKey(key), minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return nil, 0, "", fmt.Errorf("storage/s3: get %q: %w", key, err)
|
||||
}
|
||||
|
||||
// Stat the object to get size and content type.
|
||||
// GetObject is lazy — the actual HTTP request happens on Read or Stat.
|
||||
info, err := obj.Stat()
|
||||
if err != nil {
|
||||
obj.Close()
|
||||
if isS3NotFound(err) {
|
||||
return nil, 0, "", ErrNotFound
|
||||
}
|
||||
return nil, 0, "", fmt.Errorf("storage/s3: stat %q: %w", key, err)
|
||||
}
|
||||
|
||||
ct := info.ContentType
|
||||
if ct == "" {
|
||||
ct = "application/octet-stream"
|
||||
}
|
||||
|
||||
return obj, info.Size, ct, nil
|
||||
}
|
||||
|
||||
// Delete removes the object at key.
|
||||
func (s *S3Store) Delete(ctx context.Context, key string) error {
|
||||
if err := validateKey(key); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := s.client.RemoveObject(ctx, s.bucket, s.fullKey(key), minio.RemoveObjectOptions{})
|
||||
if err != nil {
|
||||
// S3 RemoveObject is already idempotent (no error for missing keys)
|
||||
return fmt.Errorf("storage/s3: delete %q: %w", key, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePrefix removes all objects under the given prefix.
|
||||
func (s *S3Store) DeletePrefix(ctx context.Context, prefix string) error {
|
||||
if err := validateKey(prefix); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fullPrefix := s.fullKey(prefix)
|
||||
// Ensure trailing slash for directory-like prefix
|
||||
if !strings.HasSuffix(fullPrefix, "/") {
|
||||
fullPrefix += "/"
|
||||
}
|
||||
|
||||
// List all objects under prefix
|
||||
objectsCh := s.client.ListObjects(ctx, s.bucket, minio.ListObjectsOptions{
|
||||
Prefix: fullPrefix,
|
||||
Recursive: true,
|
||||
})
|
||||
|
||||
// Build removal channel
|
||||
errCh := s.client.RemoveObjects(ctx, s.bucket, toRemoveChannel(objectsCh), minio.RemoveObjectsOptions{})
|
||||
|
||||
// Drain errors
|
||||
var firstErr error
|
||||
for e := range errCh {
|
||||
if e.Err != nil && firstErr == nil {
|
||||
firstErr = fmt.Errorf("storage/s3: delete prefix %q: %w", prefix, e.Err)
|
||||
}
|
||||
}
|
||||
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// Exists checks if an object exists at key.
|
||||
func (s *S3Store) Exists(ctx context.Context, key string) (bool, error) {
|
||||
if err := validateKey(key); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
_, err := s.client.StatObject(ctx, s.bucket, s.fullKey(key), minio.StatObjectOptions{})
|
||||
if err != nil {
|
||||
if isS3NotFound(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("storage/s3: exists %q: %w", key, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Healthy checks connectivity by performing a HeadBucket.
|
||||
func (s *S3Store) Healthy(ctx context.Context) error {
|
||||
exists, err := s.client.BucketExists(ctx, s.bucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage/s3: health check failed: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("storage/s3: bucket %q no longer exists", s.bucket)
|
||||
}
|
||||
// Write + read a probe object to verify full access
|
||||
probeKey := s.fullKey(".health-probe")
|
||||
_, err = s.client.PutObject(ctx, s.bucket, probeKey,
|
||||
bytes.NewReader([]byte("ok")), 2,
|
||||
minio.PutObjectOptions{ContentType: "text/plain"})
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage/s3: not writable: %w", err)
|
||||
}
|
||||
s.client.RemoveObject(ctx, s.bucket, probeKey, minio.RemoveObjectOptions{})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stats returns object count and total size in the bucket (under prefix).
|
||||
func (s *S3Store) Stats(ctx context.Context) (*StorageStats, error) {
|
||||
stats := &StorageStats{
|
||||
Backend: "s3",
|
||||
Bucket: s.bucket,
|
||||
Endpoint: s.endpoint,
|
||||
Configured: true,
|
||||
}
|
||||
|
||||
// Check health first
|
||||
if err := s.Healthy(ctx); err != nil {
|
||||
stats.Healthy = false
|
||||
return stats, nil
|
||||
}
|
||||
stats.Healthy = true
|
||||
|
||||
// Count objects under the attachments prefix
|
||||
attPrefix := s.fullKey("attachments/")
|
||||
objectsCh := s.client.ListObjects(ctx, s.bucket, minio.ListObjectsOptions{
|
||||
Prefix: attPrefix,
|
||||
Recursive: true,
|
||||
})
|
||||
|
||||
for obj := range objectsCh {
|
||||
if obj.Err != nil {
|
||||
log.Printf("storage/s3: stats list error: %v", obj.Err)
|
||||
break
|
||||
}
|
||||
stats.TotalFiles++
|
||||
stats.TotalBytes += obj.Size
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────
|
||||
|
||||
// fullKey prepends the configured prefix to a storage key.
|
||||
func (s *S3Store) fullKey(key string) string {
|
||||
return s.prefix + key
|
||||
}
|
||||
|
||||
// isS3NotFound checks if an error represents a "not found" condition.
|
||||
func isS3NotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
resp := minio.ToErrorResponse(err)
|
||||
return resp.Code == "NoSuchKey" || resp.StatusCode == 404
|
||||
}
|
||||
|
||||
// toRemoveChannel converts a ListObjects channel to a RemoveObjects input channel.
|
||||
func toRemoveChannel(objects <-chan minio.ObjectInfo) <-chan minio.ObjectInfo {
|
||||
// RemoveObjects accepts the same channel type — pass through directly.
|
||||
// The channel is already producing ObjectInfo values with Key set.
|
||||
return objects
|
||||
}
|
||||
326
server/storage/s3_test.go
Normal file
326
server/storage/s3_test.go
Normal file
@@ -0,0 +1,326 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── Unit Tests (always run) ──────────────
|
||||
|
||||
func TestS3_NewS3_Validation(t *testing.T) {
|
||||
// Missing bucket
|
||||
_, err := NewS3(S3Config{
|
||||
AccessKey: "test",
|
||||
SecretKey: "test",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error for missing bucket")
|
||||
}
|
||||
|
||||
// Missing credentials
|
||||
_, err = NewS3(S3Config{
|
||||
Bucket: "test-bucket",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error for missing credentials")
|
||||
}
|
||||
|
||||
// Missing access key only
|
||||
_, err = NewS3(S3Config{
|
||||
Bucket: "test-bucket",
|
||||
SecretKey: "test",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error for missing access key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3_FullKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
prefix string
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{"", "attachments/ch/f.txt", "attachments/ch/f.txt"},
|
||||
{"switchboard/", "attachments/ch/f.txt", "switchboard/attachments/ch/f.txt"},
|
||||
{"prefix", "attachments/ch/f.txt", "prefix/attachments/ch/f.txt"}, // trailing slash added by NewS3
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s := &S3Store{prefix: tt.prefix}
|
||||
// Normalize prefix same as NewS3 does
|
||||
if s.prefix != "" && s.prefix[len(s.prefix)-1] != '/' {
|
||||
s.prefix += "/"
|
||||
}
|
||||
got := s.fullKey(tt.key)
|
||||
if got != tt.want {
|
||||
t.Errorf("fullKey(%q, %q) = %q, want %q", tt.prefix, tt.key, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3_KeyValidation(t *testing.T) {
|
||||
// validateKey is shared between PVC and S3 — test with S3 context
|
||||
bad := []string{
|
||||
"",
|
||||
"../etc/passwd",
|
||||
"/absolute/path",
|
||||
"attachments/../../etc/shadow",
|
||||
}
|
||||
for _, key := range bad {
|
||||
if err := validateKey(key); err == nil {
|
||||
t.Errorf("validateKey(%q) should have failed", key)
|
||||
}
|
||||
}
|
||||
|
||||
good := []string{
|
||||
"attachments/ch/f.txt",
|
||||
"attachments/channel-1/att-abc_test.pdf",
|
||||
"processing/abc123/status.json",
|
||||
}
|
||||
for _, key := range good {
|
||||
if err := validateKey(key); err != nil {
|
||||
t.Errorf("validateKey(%q) failed: %v", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Integration Tests (require live S3 endpoint) ──────────────
|
||||
//
|
||||
// Set these environment variables to run:
|
||||
//
|
||||
// S3_TEST_ENDPOINT=localhost:9000
|
||||
// S3_TEST_BUCKET=switchboard-test
|
||||
// S3_TEST_ACCESS_KEY=minioadmin
|
||||
// S3_TEST_SECRET_KEY=minioadmin
|
||||
//
|
||||
// Example with MinIO:
|
||||
//
|
||||
// docker run -d -p 9000:9000 -p 9001:9001 \
|
||||
// -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin \
|
||||
// minio/minio server /data --console-address :9001
|
||||
// mc alias set local http://localhost:9000 minioadmin minioadmin
|
||||
// mc mb local/switchboard-test
|
||||
|
||||
func testS3Store(t *testing.T) *S3Store {
|
||||
t.Helper()
|
||||
|
||||
endpoint := os.Getenv("S3_TEST_ENDPOINT")
|
||||
bucket := os.Getenv("S3_TEST_BUCKET")
|
||||
accessKey := os.Getenv("S3_TEST_ACCESS_KEY")
|
||||
secretKey := os.Getenv("S3_TEST_SECRET_KEY")
|
||||
|
||||
if endpoint == "" || bucket == "" {
|
||||
t.Skip("S3_TEST_ENDPOINT and S3_TEST_BUCKET not set — skipping S3 integration tests")
|
||||
}
|
||||
|
||||
s, err := NewS3(S3Config{
|
||||
Endpoint: endpoint,
|
||||
Bucket: bucket,
|
||||
Region: "us-east-1",
|
||||
AccessKey: accessKey,
|
||||
SecretKey: secretKey,
|
||||
Prefix: "test-" + t.Name() + "/",
|
||||
ForcePathStyle: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewS3: %v", err)
|
||||
}
|
||||
|
||||
// Clean up prefix after test
|
||||
t.Cleanup(func() {
|
||||
ctx := context.Background()
|
||||
_ = s.DeletePrefix(ctx, "attachments")
|
||||
_ = s.DeletePrefix(ctx, "processing")
|
||||
})
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func TestS3_Integration_PutGetRoundTrip(t *testing.T) {
|
||||
s := testS3Store(t)
|
||||
ctx := context.Background()
|
||||
|
||||
data := []byte("hello, S3 storage world")
|
||||
key := "attachments/channel-1/att-1_test.txt"
|
||||
|
||||
err := s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
|
||||
if err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
|
||||
rc, size, ct, err := s.Get(ctx, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
if size != int64(len(data)) {
|
||||
t.Errorf("size = %d, want %d", size, len(data))
|
||||
}
|
||||
if ct != "text/plain" {
|
||||
t.Errorf("content_type = %q, want %q", ct, "text/plain")
|
||||
}
|
||||
|
||||
got, _ := io.ReadAll(rc)
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Errorf("data = %q, want %q", got, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3_Integration_GetNotFound(t *testing.T) {
|
||||
s := testS3Store(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, _, _, err := s.Get(ctx, "attachments/nonexistent/file.txt")
|
||||
if err != ErrNotFound {
|
||||
t.Errorf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3_Integration_Delete(t *testing.T) {
|
||||
s := testS3Store(t)
|
||||
ctx := context.Background()
|
||||
|
||||
key := "attachments/ch/delete-me.txt"
|
||||
_ = s.Put(ctx, key, bytes.NewReader([]byte("bye")), 3, "text/plain")
|
||||
|
||||
err := s.Delete(ctx, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
|
||||
exists, _ := s.Exists(ctx, key)
|
||||
if exists {
|
||||
t.Error("file still exists after Delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3_Integration_DeleteIdempotent(t *testing.T) {
|
||||
s := testS3Store(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// S3 delete is inherently idempotent
|
||||
err := s.Delete(ctx, "attachments/no-such/file.txt")
|
||||
if err != nil {
|
||||
t.Errorf("Delete nonexistent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3_Integration_DeletePrefix(t *testing.T) {
|
||||
s := testS3Store(t)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := "attachments/channel-abc/"
|
||||
for _, name := range []string{"a.txt", "b.png", "c.pdf"} {
|
||||
key := prefix + name
|
||||
_ = s.Put(ctx, key, bytes.NewReader([]byte("x")), 1, "text/plain")
|
||||
}
|
||||
|
||||
other := "attachments/channel-other/keep.txt"
|
||||
_ = s.Put(ctx, other, bytes.NewReader([]byte("y")), 1, "text/plain")
|
||||
|
||||
err := s.DeletePrefix(ctx, "attachments/channel-abc")
|
||||
if err != nil {
|
||||
t.Fatalf("DeletePrefix: %v", err)
|
||||
}
|
||||
|
||||
for _, name := range []string{"a.txt", "b.png", "c.pdf"} {
|
||||
exists, _ := s.Exists(ctx, prefix+name)
|
||||
if exists {
|
||||
t.Errorf("%s still exists after DeletePrefix", name)
|
||||
}
|
||||
}
|
||||
|
||||
exists, _ := s.Exists(ctx, other)
|
||||
if !exists {
|
||||
t.Error("other channel file was incorrectly deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3_Integration_Exists(t *testing.T) {
|
||||
s := testS3Store(t)
|
||||
ctx := context.Background()
|
||||
|
||||
key := "attachments/ch/exists.txt"
|
||||
_ = s.Put(ctx, key, bytes.NewReader([]byte("hi")), 2, "text/plain")
|
||||
|
||||
exists, err := s.Exists(ctx, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Exists: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Error("Exists returned false for existing file")
|
||||
}
|
||||
|
||||
exists, err = s.Exists(ctx, "attachments/ch/nope.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Exists (missing): %v", err)
|
||||
}
|
||||
if exists {
|
||||
t.Error("Exists returned true for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3_Integration_Healthy(t *testing.T) {
|
||||
s := testS3Store(t)
|
||||
if err := s.Healthy(context.Background()); err != nil {
|
||||
t.Errorf("Healthy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3_Integration_Stats(t *testing.T) {
|
||||
s := testS3Store(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Add some files
|
||||
for i := 0; i < 3; i++ {
|
||||
key := "attachments/ch/" + string(rune('a'+i)) + ".txt"
|
||||
data := bytes.Repeat([]byte("x"), 100*(i+1))
|
||||
_ = s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
|
||||
}
|
||||
|
||||
stats, err := s.Stats(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Stats: %v", err)
|
||||
}
|
||||
if stats.TotalFiles != 3 {
|
||||
t.Errorf("files = %d, want 3", stats.TotalFiles)
|
||||
}
|
||||
if stats.TotalBytes != 600 {
|
||||
t.Errorf("bytes = %d, want 600", stats.TotalBytes)
|
||||
}
|
||||
if !stats.Healthy || !stats.Configured {
|
||||
t.Error("expected healthy + configured")
|
||||
}
|
||||
if stats.Backend != "s3" {
|
||||
t.Errorf("backend = %q, want s3", stats.Backend)
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3_Integration_PutUnknownSize(t *testing.T) {
|
||||
s := testS3Store(t)
|
||||
ctx := context.Background()
|
||||
|
||||
data := []byte("unknown size upload")
|
||||
key := "attachments/ch/unknown-size.txt"
|
||||
|
||||
// size=0 means unknown — S3 should handle via multipart
|
||||
err := s.Put(ctx, key, bytes.NewReader(data), 0, "text/plain")
|
||||
if err != nil {
|
||||
t.Fatalf("Put with size=0: %v", err)
|
||||
}
|
||||
|
||||
rc, size, _, err := s.Get(ctx, key)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
if size != int64(len(data)) {
|
||||
t.Errorf("size = %d, want %d", size, len(data))
|
||||
}
|
||||
}
|
||||
73
server/storage/storage.go
Normal file
73
server/storage/storage.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
// ── Errors ─────────────────────────────────
|
||||
|
||||
var (
|
||||
// ErrNotFound is returned when a requested object does not exist.
|
||||
ErrNotFound = errors.New("storage: object not found")
|
||||
|
||||
// ErrNotConfigured is returned when storage operations are attempted
|
||||
// but no backend has been configured or the backend is unhealthy.
|
||||
ErrNotConfigured = errors.New("storage: backend not configured")
|
||||
)
|
||||
|
||||
// ── ObjectStore Interface ──────────────────
|
||||
|
||||
// ObjectStore is the abstraction for blob storage.
|
||||
// Implementations: PVC (filesystem), S3 (minio-go v7).
|
||||
//
|
||||
// Keys are slash-delimited paths relative to the storage root:
|
||||
//
|
||||
// attachments/{channel_id}/{attachment_id}_{filename}
|
||||
//
|
||||
// Implementations must create intermediate directories as needed.
|
||||
type ObjectStore interface {
|
||||
// Put writes data to the given key.
|
||||
// Creates parent directories as needed. Overwrites if exists.
|
||||
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
|
||||
|
||||
// Get returns a reader for the given key.
|
||||
// Returns the reader, size in bytes, content type, and error.
|
||||
// Caller must close the returned reader.
|
||||
// Returns ErrNotFound if key does not exist.
|
||||
Get(ctx context.Context, key string) (io.ReadCloser, int64, string, error)
|
||||
|
||||
// Delete removes the object at key.
|
||||
// No error if the key does not exist (idempotent).
|
||||
Delete(ctx context.Context, key string) error
|
||||
|
||||
// DeletePrefix removes all objects under the given prefix.
|
||||
// Used for channel deletion (bulk cleanup).
|
||||
// Example: DeletePrefix(ctx, "attachments/{channel_id}/")
|
||||
DeletePrefix(ctx context.Context, prefix string) error
|
||||
|
||||
// Exists checks if an object exists at key without reading it.
|
||||
Exists(ctx context.Context, key string) (bool, error)
|
||||
|
||||
// Healthy returns nil if the backend is operational (writable).
|
||||
Healthy(ctx context.Context) error
|
||||
|
||||
// Stats returns aggregate storage statistics.
|
||||
Stats(ctx context.Context) (*StorageStats, error)
|
||||
|
||||
// Backend returns the backend type identifier ("pvc", "s3").
|
||||
Backend() string
|
||||
}
|
||||
|
||||
// StorageStats holds aggregate storage metrics.
|
||||
type StorageStats struct {
|
||||
Backend string `json:"backend"`
|
||||
Path string `json:"path,omitempty"` // PVC only
|
||||
Endpoint string `json:"endpoint,omitempty"` // S3 only
|
||||
Bucket string `json:"bucket,omitempty"` // S3 only
|
||||
Healthy bool `json:"healthy"`
|
||||
Configured bool `json:"configured"`
|
||||
TotalFiles int64 `json:"total_files"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
}
|
||||
Reference in New Issue
Block a user