feat: add S3-compatible object storage backend

Add a new backend store that enables iSCSI targets backed by
S3-compatible object storage (AWS S3, MinIO, Ceph RGW, etc.).

The implementation uses a chunked storage strategy where the virtual
block device is divided into fixed-size chunks (default 4 MiB), each
stored as an independent S3 object. This enables efficient random
read/write access on top of object storage.

Key features:
- Chunked storage with configurable chunk size
- Sparse device support (unwritten chunks treated as zeros)
- Concurrent multi-chunk I/O via errgroup
- Per-chunk locking for safe read-modify-write
- AWS SDK v2 with default credential chain
- In-process gofakes3 test server (no Docker needed)
- 12 unit tests + 2 integration tests

Also updates CI workflow to run S3 backend tests and updates
README with S3 backend documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lei Xue
2026-03-16 16:22:57 +08:00
parent 67950263a3
commit 76ab15b0df
13 changed files with 1266 additions and 8 deletions

View File

@@ -407,6 +407,9 @@ type SCSILu struct {
PerformCommand CommandFunc
FinishCommand func(*SCSITarget, *SCSICommand)
// BackendConfig holds backend-specific configuration (e.g., *config.BackendStorage)
BackendConfig interface{} `json:"-"`
}
type LUNMap map[uint64]*SCSILu

View File

@@ -114,6 +114,16 @@ type BackendStorage struct {
NumaNode int `json:"numaNode,omitempty"`
// IoUringQueueDepth specifies the io_uring queue depth (0 for default)
IoUringQueueDepth uint32 `json:"ioUringQueueDepth,omitempty"`
// DeviceSize specifies the virtual device size in bytes (used by S3 backend)
DeviceSize uint64 `json:"deviceSize,omitempty"`
// S3ChunkSize specifies the chunk size in bytes for S3 backend (default 4MiB)
S3ChunkSize int64 `json:"s3ChunkSize,omitempty"`
// S3Endpoint specifies a custom S3 endpoint URL (for MinIO, etc.)
S3Endpoint string `json:"s3Endpoint,omitempty"`
// S3Region specifies the AWS region
S3Region string `json:"s3Region,omitempty"`
// S3ForcePathStyle uses path-style addressing (required for MinIO)
S3ForcePathStyle bool `json:"s3ForcePathStyle,omitempty"`
}
type ISCSIPortalInfo struct {

View File

@@ -63,7 +63,7 @@ func parseStoragePath(path string) (backendType, filePath string) {
possibleType := path[:idx]
// Check if it's a known backend type
switch possibleType {
case "file", "iouring", "ceph", "null", "RemBs":
case "file", "iouring", "ceph", "null", "RemBs", "s3":
return possibleType, path[idx+1:]
}
}

View File

@@ -0,0 +1,242 @@
//go:build s3integration
package s3store
import (
"bytes"
"context"
"fmt"
"math/rand"
"net/http/httptest"
"testing"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/johannesboyne/gofakes3"
"github.com/johannesboyne/gofakes3/backend/s3mem"
"github.com/gostor/gotgt/pkg/api"
"github.com/gostor/gotgt/pkg/config"
"github.com/gostor/gotgt/pkg/scsi"
)
// startFakeS3Server starts an in-process S3-compatible HTTP server using gofakes3.
func startFakeS3Server(t *testing.T) (*httptest.Server, *s3.Client) {
t.Helper()
backend := s3mem.New()
faker := gofakes3.New(backend)
ts := httptest.NewServer(faker.Server())
cfg, err := awsconfig.LoadDefaultConfig(context.Background(),
awsconfig.WithRegion("us-east-1"),
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
"accesskey", "secretkey", "",
)),
)
if err != nil {
t.Fatalf("failed to load AWS config: %v", err)
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(ts.URL)
o.UsePathStyle = true
o.ResponseChecksumValidation = aws.ResponseChecksumValidationWhenRequired
})
return ts, client
}
func TestIntegration_FullLifecycle(t *testing.T) {
ts, client := startFakeS3Server(t)
defer ts.Close()
bucket := fmt.Sprintf("gotgt-test-%d", rand.Int63())
prefix := "integration/disk0"
// Create bucket
_, err := client.CreateBucket(context.Background(), &s3.CreateBucketInput{
Bucket: aws.String(bucket),
})
if err != nil {
t.Fatalf("failed to create bucket: %v", err)
}
deviceSize := uint64(1024 * 1024) // 1 MiB
chunkSize := int64(64 * 1024) // 64 KiB
// Create and open a new S3-backed device
bs := &S3BackingStore{
BaseBackingStore: scsi.BaseBackingStore{Name: S3BackingStorage},
client: client,
}
dev := &api.SCSILu{
BackendConfig: &config.BackendStorage{
DeviceSize: deviceSize,
S3ChunkSize: chunkSize,
},
}
path := fmt.Sprintf("%s/%s", bucket, prefix)
if err := bs.Open(dev, path); err != nil {
t.Fatalf("Open failed: %v", err)
}
if bs.Size(dev) != deviceSize {
t.Fatalf("expected size=%d, got %d", deviceSize, bs.Size(dev))
}
// Write data at offset 0
pattern1 := make([]byte, 1000)
for i := range pattern1 {
pattern1[i] = byte(i % 251)
}
if err := bs.Write(pattern1, 0); err != nil {
t.Fatalf("Write pattern1 failed: %v", err)
}
// Write across chunk boundary
pattern2 := make([]byte, chunkSize+100)
for i := range pattern2 {
pattern2[i] = byte((i + 37) % 251)
}
offset2 := chunkSize - 50
if err := bs.Write(pattern2, offset2); err != nil {
t.Fatalf("Write pattern2 failed: %v", err)
}
// Read back pattern1
data1, err := bs.Read(0, 1000)
if err != nil {
t.Fatalf("Read pattern1 failed: %v", err)
}
if !bytes.Equal(data1, pattern1) {
t.Fatal("pattern1 data mismatch")
}
// Read back pattern2
data2, err := bs.Read(offset2, int64(len(pattern2)))
if err != nil {
t.Fatalf("Read pattern2 failed: %v", err)
}
if !bytes.Equal(data2, pattern2) {
t.Fatal("pattern2 data mismatch")
}
// Read sparse region (should be zeros)
sparseOffset := int64(deviceSize) - 1000
dataSparse, err := bs.Read(sparseOffset, 1000)
if err != nil {
t.Fatalf("Read sparse failed: %v", err)
}
for i, b := range dataSparse {
if b != 0 {
t.Fatalf("sparse byte %d: expected 0, got %d", i, b)
}
}
// Close and reopen to verify persistence
if err := bs.Close(dev); err != nil {
t.Fatalf("Close failed: %v", err)
}
bs2 := &S3BackingStore{
BaseBackingStore: scsi.BaseBackingStore{Name: S3BackingStorage},
client: client, // same in-memory server
}
dev2 := &api.SCSILu{
BackendConfig: &config.BackendStorage{},
}
if err := bs2.Open(dev2, path); err != nil {
t.Fatalf("Reopen failed: %v", err)
}
if bs2.Size(dev2) != deviceSize {
t.Fatalf("after reopen: expected size=%d, got %d", deviceSize, bs2.Size(dev2))
}
// Verify data persisted
data1Again, err := bs2.Read(0, 1000)
if err != nil {
t.Fatalf("Re-read pattern1 failed: %v", err)
}
if !bytes.Equal(data1Again, pattern1) {
t.Fatal("pattern1 data mismatch after reopen")
}
// Test unmap (full chunk)
if err := bs2.Unmap([]api.UnmapBlockDescriptor{
{Offset: 0, TL: uint64(chunkSize)},
}); err != nil {
t.Fatalf("Unmap failed: %v", err)
}
// Read unmapped region - should be zeros
dataUnmapped, err := bs2.Read(0, 100)
if err != nil {
t.Fatalf("Read unmapped failed: %v", err)
}
for i, b := range dataUnmapped {
if b != 0 {
t.Fatalf("unmapped byte %d: expected 0, got %d", i, b)
}
}
bs2.Close(dev2)
t.Log("S3 integration test: full lifecycle passed")
}
func TestIntegration_LargeWriteRead(t *testing.T) {
ts, client := startFakeS3Server(t)
defer ts.Close()
bucket := fmt.Sprintf("gotgt-large-%d", rand.Int63())
_, err := client.CreateBucket(context.Background(), &s3.CreateBucketInput{
Bucket: aws.String(bucket),
})
if err != nil {
t.Fatalf("failed to create bucket: %v", err)
}
deviceSize := uint64(4 * 1024 * 1024) // 4 MiB
chunkSize := int64(256 * 1024) // 256 KiB
bs := &S3BackingStore{
BaseBackingStore: scsi.BaseBackingStore{Name: S3BackingStorage},
client: client,
}
dev := &api.SCSILu{
BackendConfig: &config.BackendStorage{
DeviceSize: deviceSize,
S3ChunkSize: chunkSize,
},
}
path := fmt.Sprintf("%s/large/disk0", bucket)
if err := bs.Open(dev, path); err != nil {
t.Fatalf("Open failed: %v", err)
}
// Write 1MiB of data spanning multiple chunks
writeSize := 1024 * 1024
data := make([]byte, writeSize)
for i := range data {
data[i] = byte(i % 256)
}
if err := bs.Write(data, 0); err != nil {
t.Fatalf("Large write failed: %v", err)
}
// Read it back
readBack, err := bs.Read(0, int64(writeSize))
if err != nil {
t.Fatalf("Large read failed: %v", err)
}
if !bytes.Equal(readBack, data) {
t.Fatal("large write/read data mismatch")
}
bs.Close(dev)
t.Log("S3 integration test: large write/read passed")
}

View File

@@ -0,0 +1,470 @@
/*
Copyright 2024 The GoStor Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package s3store
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"sync"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
log "github.com/sirupsen/logrus"
"golang.org/x/sync/errgroup"
"github.com/gostor/gotgt/pkg/api"
"github.com/gostor/gotgt/pkg/config"
"github.com/gostor/gotgt/pkg/scsi"
)
const (
S3BackingStorage = "s3"
DefaultChunkSize = 4 * 1024 * 1024 // 4 MiB
metadataKey = "_metadata"
)
func init() {
scsi.RegisterBackingStore(S3BackingStorage, newS3)
}
// s3Client is the minimal S3 interface used by this package, enabling test injection.
type s3Client interface {
GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error)
PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error)
DeleteObject(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error)
}
// deviceMetadata stores device configuration in S3.
type deviceMetadata struct {
DeviceSize uint64 `json:"deviceSize"`
ChunkSize int64 `json:"chunkSize"`
}
// S3BackingStore implements the BackingStore interface using S3-compatible object storage.
// The virtual block device is divided into fixed-size chunks, each stored as a separate S3 object.
type S3BackingStore struct {
scsi.BaseBackingStore
client s3Client
bucket string
prefix string
chunkSize int64
chunkLocks sync.Map // map[int64]*sync.Mutex
}
func newS3() (api.BackingStore, error) {
return &S3BackingStore{
BaseBackingStore: scsi.BaseBackingStore{
Name: S3BackingStorage,
DataSize: 0,
OflagsSupported: 0,
},
}, nil
}
func (bs *S3BackingStore) lockChunk(idx int64) *sync.Mutex {
val, _ := bs.chunkLocks.LoadOrStore(idx, &sync.Mutex{})
mu := val.(*sync.Mutex)
mu.Lock()
return mu
}
func (bs *S3BackingStore) chunkKey(idx int64) string {
return fmt.Sprintf("%s/chunk_%010d", bs.prefix, idx)
}
func (bs *S3BackingStore) metadataObjKey() string {
return fmt.Sprintf("%s/%s", bs.prefix, metadataKey)
}
func (bs *S3BackingStore) Open(dev *api.SCSILu, path string) error {
// Parse path: bucket/prefix
idx := strings.Index(path, "/")
if idx <= 0 {
return fmt.Errorf("invalid S3 path %q, expected bucket/prefix", path)
}
bs.bucket = path[:idx]
bs.prefix = path[idx+1:]
if bs.prefix == "" {
return fmt.Errorf("invalid S3 path %q, prefix cannot be empty", path)
}
// Read backend-specific config
var (
chunkSize int64
deviceSize uint64
endpoint string
region string
forcePathStyle bool
)
if cfg, ok := dev.BackendConfig.(*config.BackendStorage); ok && cfg != nil {
chunkSize = cfg.S3ChunkSize
deviceSize = cfg.DeviceSize
endpoint = cfg.S3Endpoint
region = cfg.S3Region
forcePathStyle = cfg.S3ForcePathStyle
}
if chunkSize <= 0 {
chunkSize = DefaultChunkSize
}
bs.chunkSize = chunkSize
// Create S3 client if not already set (e.g., by tests)
if bs.client == nil {
ctx := context.Background()
var opts []func(*awsconfig.LoadOptions) error
if region != "" {
opts = append(opts, awsconfig.WithRegion(region))
}
cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...)
if err != nil {
return fmt.Errorf("failed to load AWS config: %w", err)
}
var s3Opts []func(*s3.Options)
if endpoint != "" {
s3Opts = append(s3Opts, func(o *s3.Options) {
o.BaseEndpoint = aws.String(endpoint)
})
}
if forcePathStyle {
s3Opts = append(s3Opts, func(o *s3.Options) {
o.UsePathStyle = true
})
}
// Disable response checksum validation for compatibility with
// S3-compatible backends (e.g., MinIO) and range read requests.
s3Opts = append(s3Opts, func(o *s3.Options) {
o.ResponseChecksumValidation = aws.ResponseChecksumValidationWhenRequired
})
bs.client = s3.NewFromConfig(cfg, s3Opts...)
}
// Try to load existing metadata
ctx := context.Background()
meta, err := bs.loadMetadata(ctx)
if err != nil {
var nsk *types.NoSuchKey
if !errors.As(err, &nsk) {
return fmt.Errorf("failed to load S3 metadata: %w", err)
}
// Metadata does not exist - create new device
if deviceSize == 0 {
return fmt.Errorf("S3 device metadata not found and deviceSize not configured")
}
meta = &deviceMetadata{
DeviceSize: deviceSize,
ChunkSize: chunkSize,
}
if err := bs.saveMetadata(ctx, meta); err != nil {
return fmt.Errorf("failed to save S3 metadata: %w", err)
}
log.Infof("S3 backing store: created new device %s/%s, size=%d, chunkSize=%d",
bs.bucket, bs.prefix, deviceSize, chunkSize)
} else {
bs.chunkSize = meta.ChunkSize
log.Infof("S3 backing store: opened existing device %s/%s, size=%d, chunkSize=%d",
bs.bucket, bs.prefix, meta.DeviceSize, meta.ChunkSize)
}
bs.DataSize = meta.DeviceSize
return nil
}
func (bs *S3BackingStore) loadMetadata(ctx context.Context) (*deviceMetadata, error) {
key := bs.metadataObjKey()
out, err := bs.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bs.bucket),
Key: aws.String(key),
})
if err != nil {
return nil, err
}
defer out.Body.Close()
var meta deviceMetadata
if err := json.NewDecoder(out.Body).Decode(&meta); err != nil {
return nil, fmt.Errorf("failed to decode metadata: %w", err)
}
return &meta, nil
}
func (bs *S3BackingStore) saveMetadata(ctx context.Context, meta *deviceMetadata) error {
data, err := json.Marshal(meta)
if err != nil {
return err
}
key := bs.metadataObjKey()
_, err = bs.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bs.bucket),
Key: aws.String(key),
Body: bytes.NewReader(data),
})
return err
}
func (bs *S3BackingStore) Close(dev *api.SCSILu) error {
bs.client = nil
return nil
}
func (bs *S3BackingStore) Init(dev *api.SCSILu, Opts string) error {
return nil
}
func (bs *S3BackingStore) Exit(dev *api.SCSILu) error {
return nil
}
func (bs *S3BackingStore) Size(dev *api.SCSILu) uint64 {
return bs.DataSize
}
func (bs *S3BackingStore) Read(offset, tl int64) ([]byte, error) {
if bs.client == nil {
return nil, fmt.Errorf("S3 backend store is not open")
}
result := make([]byte, tl)
startChunk := offset / bs.chunkSize
endChunk := (offset + tl - 1) / bs.chunkSize
ctx := context.Background()
eg, ctx := errgroup.WithContext(ctx)
for ci := startChunk; ci <= endChunk; ci++ {
ci := ci
eg.Go(func() error {
chunkStart := ci * bs.chunkSize
readStart := max(offset, chunkStart) - chunkStart
readEnd := min(offset+tl, chunkStart+bs.chunkSize) - chunkStart
data, err := bs.getChunkRange(ctx, ci, readStart, readEnd)
if err != nil {
return err
}
destStart := max(offset, chunkStart) - offset
copy(result[destStart:], data)
return nil
})
}
if err := eg.Wait(); err != nil {
return nil, err
}
return result, nil
}
// ReadAt reads directly into the provided buffer to avoid allocation.
func (bs *S3BackingStore) ReadAt(buf []byte, offset int64) (int, error) {
data, err := bs.Read(offset, int64(len(buf)))
if err != nil {
return 0, err
}
copy(buf, data)
return len(buf), nil
}
// getChunkRange reads a byte range from a chunk. Returns zeros if the chunk does not exist.
func (bs *S3BackingStore) getChunkRange(ctx context.Context, chunkIdx, start, end int64) ([]byte, error) {
key := bs.chunkKey(chunkIdx)
rangeStr := fmt.Sprintf("bytes=%d-%d", start, end-1)
out, err := bs.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bs.bucket),
Key: aws.String(key),
Range: aws.String(rangeStr),
})
if err != nil {
var nsk *types.NoSuchKey
if errors.As(err, &nsk) {
// Chunk doesn't exist - return zeros (sparse)
return make([]byte, end-start), nil
}
return nil, fmt.Errorf("failed to read chunk %d: %w", chunkIdx, err)
}
defer out.Body.Close()
data, err := io.ReadAll(out.Body)
if err != nil {
return nil, fmt.Errorf("failed to read chunk %d body: %w", chunkIdx, err)
}
// Pad with zeros if chunk is shorter than expected range
expected := int(end - start)
if len(data) < expected {
padded := make([]byte, expected)
copy(padded, data)
return padded, nil
}
return data, nil
}
func (bs *S3BackingStore) Write(wbuf []byte, offset int64) error {
if bs.client == nil {
return fmt.Errorf("S3 backend store is not open")
}
tl := int64(len(wbuf))
startChunk := offset / bs.chunkSize
endChunk := (offset + tl - 1) / bs.chunkSize
ctx := context.Background()
eg, ctx := errgroup.WithContext(ctx)
for ci := startChunk; ci <= endChunk; ci++ {
ci := ci
eg.Go(func() error {
chunkStart := ci * bs.chunkSize
writeStart := max(offset, chunkStart) - chunkStart
writeEnd := min(offset+tl, chunkStart+bs.chunkSize) - chunkStart
srcStart := max(offset, chunkStart) - offset
srcEnd := srcStart + (writeEnd - writeStart)
if writeStart == 0 && writeEnd == bs.chunkSize {
// Full chunk write - direct upload
return bs.putChunk(ctx, ci, wbuf[srcStart:srcEnd])
}
// Partial chunk - read-modify-write
return bs.readModifyWriteChunk(ctx, ci, writeStart, writeEnd, wbuf[srcStart:srcEnd])
})
}
return eg.Wait()
}
func (bs *S3BackingStore) putChunk(ctx context.Context, chunkIdx int64, data []byte) error {
key := bs.chunkKey(chunkIdx)
_, err := bs.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bs.bucket),
Key: aws.String(key),
Body: bytes.NewReader(data),
})
if err != nil {
return fmt.Errorf("failed to write chunk %d: %w", chunkIdx, err)
}
return nil
}
func (bs *S3BackingStore) readModifyWriteChunk(ctx context.Context, chunkIdx, writeStart, writeEnd int64, data []byte) error {
mu := bs.lockChunk(chunkIdx)
defer mu.Unlock()
// Read existing chunk
chunk, err := bs.getFullChunk(ctx, chunkIdx)
if err != nil {
return err
}
// Modify
copy(chunk[writeStart:writeEnd], data)
// Write back
return bs.putChunk(ctx, chunkIdx, chunk)
}
// getFullChunk reads the full chunk, returning a zero-filled buffer if it doesn't exist.
func (bs *S3BackingStore) getFullChunk(ctx context.Context, chunkIdx int64) ([]byte, error) {
key := bs.chunkKey(chunkIdx)
out, err := bs.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bs.bucket),
Key: aws.String(key),
})
if err != nil {
var nsk *types.NoSuchKey
if errors.As(err, &nsk) {
return make([]byte, bs.chunkSize), nil
}
return nil, fmt.Errorf("failed to read full chunk %d: %w", chunkIdx, err)
}
defer out.Body.Close()
data, err := io.ReadAll(out.Body)
if err != nil {
return nil, fmt.Errorf("failed to read full chunk %d body: %w", chunkIdx, err)
}
if int64(len(data)) < bs.chunkSize {
padded := make([]byte, bs.chunkSize)
copy(padded, data)
return padded, nil
}
return data, nil
}
func (bs *S3BackingStore) DataSync(offset, tl int64) error {
// S3 PutObject is durable once acknowledged
return nil
}
func (bs *S3BackingStore) DataAdvise(offset, length int64, advise uint32) error {
return nil
}
func (bs *S3BackingStore) Unmap(descriptors []api.UnmapBlockDescriptor) error {
if bs.client == nil {
return fmt.Errorf("S3 backend store is not open")
}
ctx := context.Background()
for _, desc := range descriptors {
startChunk := int64(desc.Offset) / bs.chunkSize
endChunk := int64(desc.Offset+desc.TL-1) / bs.chunkSize
for ci := startChunk; ci <= endChunk; ci++ {
chunkStart := ci * bs.chunkSize
unmapStart := max(int64(desc.Offset), chunkStart) - chunkStart
unmapEnd := min(int64(desc.Offset+desc.TL), chunkStart+bs.chunkSize) - chunkStart
if unmapStart == 0 && unmapEnd == bs.chunkSize {
// Full chunk unmap - delete the object
key := bs.chunkKey(ci)
if _, err := bs.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(bs.bucket),
Key: aws.String(key),
}); err != nil {
return fmt.Errorf("failed to delete chunk %d: %w", ci, err)
}
}
// Partial chunk unmap - ignore (sparse zeros on next read is fine
// since missing chunk data is treated as zeros)
}
}
return nil
}
func max(a, b int64) int64 {
if a > b {
return a
}
return b
}
func min(a, b int64) int64 {
if a < b {
return a
}
return b
}

View File

@@ -0,0 +1,359 @@
package s3store
import (
"bytes"
"context"
"fmt"
"io"
"sync"
"testing"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/gostor/gotgt/pkg/api"
"github.com/gostor/gotgt/pkg/config"
"github.com/gostor/gotgt/pkg/scsi"
)
// fakeS3Client is an in-memory S3 client for testing.
type fakeS3Client struct {
mu sync.Mutex
objects map[string][]byte // key -> data
}
func newFakeS3Client() *fakeS3Client {
return &fakeS3Client{objects: make(map[string][]byte)}
}
func (f *fakeS3Client) GetObject(_ context.Context, input *s3.GetObjectInput, _ ...func(*s3.Options)) (*s3.GetObjectOutput, error) {
f.mu.Lock()
defer f.mu.Unlock()
key := *input.Key
data, ok := f.objects[key]
if !ok {
return nil, &types.NoSuchKey{}
}
if input.Range != nil {
var start, end int64
_, err := fmt.Sscanf(*input.Range, "bytes=%d-%d", &start, &end)
if err == nil && start >= 0 && end < int64(len(data)) {
data = data[start : end+1]
}
}
return &s3.GetObjectOutput{
Body: io.NopCloser(bytes.NewReader(data)),
}, nil
}
func (f *fakeS3Client) PutObject(_ context.Context, input *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) {
f.mu.Lock()
defer f.mu.Unlock()
key := *input.Key
data, err := io.ReadAll(input.Body)
if err != nil {
return nil, err
}
f.objects[key] = data
return &s3.PutObjectOutput{}, nil
}
func (f *fakeS3Client) DeleteObject(_ context.Context, input *s3.DeleteObjectInput, _ ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.objects, *input.Key)
return &s3.DeleteObjectOutput{}, nil
}
func newTestStore(client *fakeS3Client, chunkSize int64, deviceSize uint64) *S3BackingStore {
return &S3BackingStore{
BaseBackingStore: scsi.BaseBackingStore{
Name: S3BackingStorage,
DataSize: deviceSize,
},
client: client,
bucket: "test-bucket",
prefix: "test/disk",
chunkSize: chunkSize,
}
}
func TestOpen_NewDevice(t *testing.T) {
client := newFakeS3Client()
bs := &S3BackingStore{
BaseBackingStore: scsi.BaseBackingStore{Name: S3BackingStorage},
client: client,
}
dev := &api.SCSILu{
BackendConfig: &config.BackendStorage{
DeviceSize: 1024 * 1024, // 1MiB
},
}
err := bs.Open(dev, "mybucket/myprefix")
if err != nil {
t.Fatalf("Open failed: %v", err)
}
if bs.bucket != "mybucket" {
t.Errorf("expected bucket=mybucket, got %s", bs.bucket)
}
if bs.prefix != "myprefix" {
t.Errorf("expected prefix=myprefix, got %s", bs.prefix)
}
if bs.DataSize != 1024*1024 {
t.Errorf("expected DataSize=1048576, got %d", bs.DataSize)
}
// Verify metadata was saved
if _, ok := client.objects["myprefix/_metadata"]; !ok {
t.Error("metadata object was not created")
}
}
func TestOpen_ExistingDevice(t *testing.T) {
client := newFakeS3Client()
// Pre-populate metadata
client.objects["myprefix/_metadata"] = []byte(`{"deviceSize":2097152,"chunkSize":1048576}`)
bs := &S3BackingStore{
BaseBackingStore: scsi.BaseBackingStore{Name: S3BackingStorage},
client: client,
}
dev := &api.SCSILu{
BackendConfig: &config.BackendStorage{},
}
err := bs.Open(dev, "mybucket/myprefix")
if err != nil {
t.Fatalf("Open failed: %v", err)
}
if bs.DataSize != 2097152 {
t.Errorf("expected DataSize=2097152, got %d", bs.DataSize)
}
if bs.chunkSize != 1048576 {
t.Errorf("expected chunkSize=1048576, got %d", bs.chunkSize)
}
}
func TestOpen_InvalidPath(t *testing.T) {
bs := &S3BackingStore{
BaseBackingStore: scsi.BaseBackingStore{Name: S3BackingStorage},
client: newFakeS3Client(),
}
dev := &api.SCSILu{BackendConfig: &config.BackendStorage{}}
err := bs.Open(dev, "nobucket")
if err == nil {
t.Error("expected error for invalid path")
}
}
func TestRead_SingleChunk(t *testing.T) {
client := newFakeS3Client()
bs := newTestStore(client, 1024, 4096)
// Write a chunk
chunkData := make([]byte, 1024)
for i := range chunkData {
chunkData[i] = 0xAB
}
client.objects["test/disk/chunk_0000000000"] = chunkData
data, err := bs.Read(100, 200)
if err != nil {
t.Fatalf("Read failed: %v", err)
}
if len(data) != 200 {
t.Fatalf("expected 200 bytes, got %d", len(data))
}
for i, b := range data {
if b != 0xAB {
t.Fatalf("byte %d: expected 0xAB, got 0x%02X", i, b)
}
}
}
func TestRead_CrossChunk(t *testing.T) {
client := newFakeS3Client()
bs := newTestStore(client, 100, 1000)
// Chunk 0: bytes 0-99, fill with 0xAA
chunk0 := make([]byte, 100)
for i := range chunk0 {
chunk0[i] = 0xAA
}
client.objects["test/disk/chunk_0000000000"] = chunk0
// Chunk 1: bytes 100-199, fill with 0xBB
chunk1 := make([]byte, 100)
for i := range chunk1 {
chunk1[i] = 0xBB
}
client.objects["test/disk/chunk_0000000001"] = chunk1
// Read across boundary: 50-149
data, err := bs.Read(50, 100)
if err != nil {
t.Fatalf("Read failed: %v", err)
}
if len(data) != 100 {
t.Fatalf("expected 100 bytes, got %d", len(data))
}
// First 50 bytes from chunk0 (0xAA)
for i := 0; i < 50; i++ {
if data[i] != 0xAA {
t.Fatalf("byte %d: expected 0xAA, got 0x%02X", i, data[i])
}
}
// Last 50 bytes from chunk1 (0xBB)
for i := 50; i < 100; i++ {
if data[i] != 0xBB {
t.Fatalf("byte %d: expected 0xBB, got 0x%02X", i, data[i])
}
}
}
func TestRead_SparseChunk(t *testing.T) {
client := newFakeS3Client()
bs := newTestStore(client, 100, 1000)
// Don't create any chunks - should return zeros
data, err := bs.Read(0, 100)
if err != nil {
t.Fatalf("Read failed: %v", err)
}
for i, b := range data {
if b != 0 {
t.Fatalf("byte %d: expected 0, got 0x%02X", i, b)
}
}
}
func TestWrite_SingleChunk(t *testing.T) {
client := newFakeS3Client()
bs := newTestStore(client, 1024, 4096)
wbuf := []byte{1, 2, 3, 4, 5}
err := bs.Write(wbuf, 10)
if err != nil {
t.Fatalf("Write failed: %v", err)
}
// The chunk should exist now (read-modify-write since it's partial)
chunk, ok := client.objects["test/disk/chunk_0000000000"]
if !ok {
t.Fatal("chunk was not created")
}
if len(chunk) != 1024 {
t.Fatalf("chunk size: expected 1024, got %d", len(chunk))
}
// Verify written data
for i, b := range wbuf {
if chunk[10+i] != b {
t.Fatalf("byte %d: expected %d, got %d", 10+i, b, chunk[10+i])
}
}
// Verify zeros around it
if chunk[9] != 0 || chunk[15] != 0 {
t.Fatal("surrounding bytes should be zero")
}
}
func TestWrite_FullChunk(t *testing.T) {
client := newFakeS3Client()
bs := newTestStore(client, 8, 64)
// Write exactly one full chunk
wbuf := []byte{1, 2, 3, 4, 5, 6, 7, 8}
err := bs.Write(wbuf, 0)
if err != nil {
t.Fatalf("Write failed: %v", err)
}
chunk := client.objects["test/disk/chunk_0000000000"]
if !bytes.Equal(chunk, wbuf) {
t.Fatalf("chunk data mismatch: %v vs %v", chunk, wbuf)
}
}
func TestWrite_CrossChunk(t *testing.T) {
client := newFakeS3Client()
bs := newTestStore(client, 8, 64)
// Write across chunk boundary (offset 6, len 4 -> chunks 0 and 1)
wbuf := []byte{0xA1, 0xA2, 0xA3, 0xA4}
err := bs.Write(wbuf, 6)
if err != nil {
t.Fatalf("Write failed: %v", err)
}
// Verify chunk 0: bytes 6-7 should be 0xA1, 0xA2
chunk0 := client.objects["test/disk/chunk_0000000000"]
if chunk0[6] != 0xA1 || chunk0[7] != 0xA2 {
t.Fatalf("chunk0 data mismatch at boundary: %v", chunk0[6:8])
}
// Verify chunk 1: bytes 0-1 should be 0xA3, 0xA4
chunk1 := client.objects["test/disk/chunk_0000000001"]
if chunk1[0] != 0xA3 || chunk1[1] != 0xA4 {
t.Fatalf("chunk1 data mismatch at boundary: %v", chunk1[0:2])
}
}
func TestWriteThenRead(t *testing.T) {
client := newFakeS3Client()
bs := newTestStore(client, 100, 1000)
// Write pattern across chunks
wbuf := make([]byte, 250)
for i := range wbuf {
wbuf[i] = byte(i % 256)
}
err := bs.Write(wbuf, 50)
if err != nil {
t.Fatalf("Write failed: %v", err)
}
// Read back
data, err := bs.Read(50, 250)
if err != nil {
t.Fatalf("Read failed: %v", err)
}
if !bytes.Equal(data, wbuf) {
t.Fatal("read data does not match written data")
}
}
func TestUnmap_FullChunk(t *testing.T) {
client := newFakeS3Client()
bs := newTestStore(client, 100, 1000)
// Create chunk
client.objects["test/disk/chunk_0000000001"] = make([]byte, 100)
err := bs.Unmap([]api.UnmapBlockDescriptor{
{Offset: 100, TL: 100}, // exactly chunk 1
})
if err != nil {
t.Fatalf("Unmap failed: %v", err)
}
if _, ok := client.objects["test/disk/chunk_0000000001"]; ok {
t.Error("chunk should have been deleted")
}
}
func TestSize(t *testing.T) {
bs := newTestStore(newFakeS3Client(), 1024, 8192)
dev := &api.SCSILu{}
if bs.Size(dev) != 8192 {
t.Errorf("expected 8192, got %d", bs.Size(dev))
}
}

View File

@@ -46,7 +46,7 @@ func NewSCSILu(bs *config.BackendStorage) (*api.SCSILu, error) {
// Validate backend type, default to file if unknown
switch backendType {
case "file", "iouring", "ceph", "null", "RemBs":
case "file", "iouring", "ceph", "null", "RemBs", "s3":
// Valid types
default:
// Unknown type, treat entire path as file path
@@ -67,6 +67,7 @@ func NewSCSILu(bs *config.BackendStorage) (*api.SCSILu, error) {
Storage: backing,
BlockShift: bs.BlockShift,
UUID: bs.DeviceID,
BackendConfig: bs,
}
err = backing.Open(lu, backendPath)