Changeset 0.12.0 (#63)

This commit is contained in:
2026-02-25 21:38:49 +00:00
parent c9d8e9457e
commit 88216ec4cb
59 changed files with 13115 additions and 139 deletions

78
server/storage/init.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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"`
}