Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .github/workflows/s3-compatibility.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
name: S3 Compatibility

on:
push:
branches:
- main
pull_request:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
compatibility:
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
timeout-minutes: 25
env:
GOTOOLCHAIN: auto
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true

- name: Validate report rules
run: python3 -m unittest discover -s scripts -p 'test_s3_compatibility_report.py'

- name: Install pinned S3 clients
run: |
set -euo pipefail
tools_dir="$(mktemp -d)"
case "$(uname -m)" in
x86_64) aws_arch=x86_64; client_arch=amd64 ;;
aarch64|arm64) aws_arch=aarch64; client_arch=arm64 ;;
*) echo "Unsupported S3 client architecture" >&2; exit 1 ;;
esac
mkdir -p "$RUNNER_TEMP/s3-client-bin"
curl --fail --location --retry 3 --silent --show-error \
"https://awscli.amazonaws.com/awscli-exe-linux-${aws_arch}-2.31.0.zip" -o "$tools_dir/aws.zip"
unzip -q "$tools_dir/aws.zip" -d "$tools_dir"
"$tools_dir/aws/install" --install-dir "$RUNNER_TEMP/aws-cli" --bin-dir "$RUNNER_TEMP/s3-client-bin"
curl --fail --location --retry 3 --silent --show-error \
"https://downloads.rclone.org/v1.71.0/rclone-v1.71.0-linux-${client_arch}.zip" -o "$tools_dir/rclone.zip"
unzip -q "$tools_dir/rclone.zip" -d "$tools_dir"
install "$tools_dir/rclone-v1.71.0-linux-${client_arch}/rclone" "$RUNNER_TEMP/s3-client-bin/rclone"
curl --fail --location --retry 3 --silent --show-error \
"https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-${client_arch}.RELEASE.2025-08-13T08-35-41Z" \
-o "$RUNNER_TEMP/s3-client-bin/mc"
chmod +x "$RUNNER_TEMP/s3-client-bin/mc"
echo "$RUNNER_TEMP/s3-client-bin" >> "$GITHUB_PATH"

- name: Test documented S3 operations
run: |
go test -json -trimpath -tags='dev systemtest s3compat' -count=1 \
-run '^TestS3CompatibilityMatrix$' ./tests/system > "$RUNNER_TEMP/s3-matrix.json"

- name: Test AWS CLI, rclone, and MinIO Client
if: ${{ !cancelled() }}
run: |
go test -json -trimpath -tags='dev systemtest s3compat' -count=1 \
-run '^TestS3Clients$' ./tests/system > "$RUNNER_TEMP/s3-clients.json"

- name: Publish compatibility summary
if: ${{ always() }}
env:
SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
run: |
python3 scripts/s3_compatibility_report.py \
--matrix-json "$RUNNER_TEMP/s3-matrix.json" \
--clients-json "$RUNNER_TEMP/s3-clients.json" \
--summary "$GITHUB_STEP_SUMMARY" \
--source-sha "$SOURCE_SHA" \
--checkout-sha "$(git rev-parse HEAD)"
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ LDFLAGS := -X $(MODULE)/internal/buildinfo.Version=$(VERSION) \
-X $(MODULE)/internal/buildinfo.Commit=$(COMMIT) \
-X $(MODULE)/internal/buildinfo.Date=$(DATE)

.PHONY: all build build-go build-systemtest-server build-integration-server docs-build test test-fast test-race test-system test-integration test-ui-e2e test-docker-entrypoint test-docker-deployment lint fmt check verify-e2e verify-fast verify-norace verify-race clean run ui-install ui-build ui-dev ui-e2e-install
.PHONY: all build build-go build-systemtest-server build-integration-server docs-build test test-fast test-race test-system test-s3-compatibility test-s3-clients test-integration test-ui-e2e test-docker-entrypoint test-docker-deployment lint fmt check verify-e2e verify-fast verify-norace verify-race clean run ui-install ui-build ui-dev ui-e2e-install
.PHONY: docker-init docker-up docker-verify docker-down docker-status docker-logs docker-password

all: build
Expand Down Expand Up @@ -55,6 +55,12 @@ test-race:
test-system:
$(CGO) go test $(GOFLAGS) -tags='dev systemtest' -count=1 ./tests/testutil/... ./internal/systemtest ./tests/system

test-s3-compatibility:
$(CGO) go test $(GOFLAGS) -tags='dev systemtest s3compat' -count=1 -run '^(TestS3CompatibilityMatrix|TestS3Clients)$$' ./tests/system

test-s3-clients:
$(CGO) go test $(GOFLAGS) -tags='dev systemtest s3compat' -count=1 -run '^TestS3Clients$$' ./tests/system

test-integration: build-integration-server
$(CGO) go test -v $(GOFLAGS) -tags=integration -count=1 -timeout=45m ./tests/integration/...

Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# SynapS3

[![CI](https://github.com/strahe/SynapS3/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/strahe/SynapS3/actions/workflows/ci.yml)
[![S3 Compatibility](https://github.com/strahe/SynapS3/actions/workflows/s3-compatibility.yml/badge.svg?branch=main)](https://github.com/strahe/SynapS3/actions/workflows/s3-compatibility.yml)
[![Package](https://img.shields.io/badge/package-GHCR-blue?logo=github)](https://github.com/strahe/SynapS3/pkgs/container/synaps3)
[![Go Report](https://goreportcard.com/badge/github.com/strahe/synaps3)](https://goreportcard.com/report/github.com/strahe/synaps3)
[![Go Version](https://img.shields.io/github/go-mod/go-version/strahe/SynapS3?filename=go.mod)](go.mod)

SynapS3 is an open-source, self-hosted S3-compatible gateway for Filecoin storage.
Expand Down Expand Up @@ -50,7 +50,7 @@ Writes commit to local cache and metadata before returning success. Reads use lo
| Object | `ListObjects` | ✅ | Marker pagination |
| Object | `ListObjectsV2` | ✅ | Continuation-token pagination |
| Object | `ListObjectVersions` | ✅ | Lists object versions and delete markers |
| Object | `GetObjectAttributes` | ✅ | Reports metadata and multipart `ObjectParts`; `TotalPartsCount` is not emitted |
| Object | `GetObjectAttributes` | ⚠️ | Reports metadata and multipart `ObjectParts`; `TotalPartsCount` is not emitted |
| Multipart | `CreateMultipartUpload` | ✅ | Starts an upload |
| Multipart | `UploadPart` | ✅ | Uploads one part |
| Multipart | `UploadPartCopy` | ⚠️ | Whole-object copy only; range copy is not supported |
Expand All @@ -59,6 +59,8 @@ Writes commit to local cache and metadata before returning success. Reads use lo
| Multipart | `ListMultipartUploads` | ✅ | Lists open uploads |
| Multipart | `ListParts` | ✅ | Lists uploaded parts |

See [S3 Compatibility runs](https://github.com/strahe/SynapS3/actions/workflows/s3-compatibility.yml) for individual reports.

## License

See [LICENSE](LICENSE).
2 changes: 2 additions & 0 deletions docs/en/concepts/write-path-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ The S3 response does not wait for Filecoin provider latency. After the write is

`GetObject` reads local cache first. If the cache entry is missing and an available remote copy is recorded, SynapS3 can retrieve the object from the storage provider, verify it, serve the response, and restore the local cache when possible.

A single byte-range request returns only the requested bytes. On a cache miss, SynapS3 still downloads the complete remote object to verify it before the range response finishes; a complete read can also restore the local cache. Closing the request early does not leave a partial cache entry.

Successful foreground cache opens refresh the entry's LRU access time. This includes S3 object and range reads, cached CopyObject sources, Admin content downloads, and version restores. Metadata-only operations such as `HeadObject` do not refresh it, and the background Uploader does not make an entry look recently used. A complete remote rehydration starts a new LRU age for the restored entry.

Repeated reads of the same version coalesce access-time updates to at most one database write per minute. Access tracking is best effort and never turns a successful read into an error.
Expand Down
4 changes: 2 additions & 2 deletions docs/en/reference/s3-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ SynapS3 mainly supports path-style S3 access for writing bucket and object data
| Ownership controls | `PutBucketOwnershipControls` | Partial | Accepts only `BucketOwnerPreferred`; rejects other ownership modes. |
| Ownership controls | `DeleteBucketOwnershipControls` | Partial | Keeps the ACL-compatible `BucketOwnerPreferred` behavior. |
| Object | `PutObject` | Supported | Stores an object through the cache-first write model. |
| Object | `GetObject` | Supported | Reads from cache or committed remote storage. |
| Object | `HeadObject` | Supported | Reads object metadata. |
| Object | `GetObject` | Supported | Reads from cache or committed remote storage, including single byte ranges, version metadata, and `Last-Modified`. A cold remote range still downloads the complete source for integrity verification. |
| Object | `HeadObject` | Supported | Reads object metadata, including custom metadata on the requested version. |
| Object | `DeleteObject` | Supported | Creates a delete marker without `versionId`; with `versionId`, deletes an eligible data version or delete marker. |
| Object | `DeleteObjects` | Supported | Applies the same version-aware deletion rules to each entry and reports entry-specific failures. |
| Object | `CopyObject` | Supported | Source object must be readable from cache or committed remote storage. |
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/concepts/write-path-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ SynapS3 会校验请求,保存对象及其元数据,再返回 S3 兼容的 E

`GetObject` 会先读本地缓存。缓存缺失时,如果记录了可用的远端副本,SynapS3 可以从存储提供方取回并校验对象、返回响应,并在可能时恢复本地缓存。

单段字节 Range 请求只返回所需字节。缓存缺失时,SynapS3 仍会下载完整远端对象,并在区间响应完成前校验内容;完整读取也可以恢复本地缓存。请求提前关闭不会留下不完整的缓存条目。

前台成功打开缓存时,会刷新该条目的 LRU 访问时间。这包括 S3 对象和 Range 读取、命中缓存的 CopyObject 源、Admin 内容下载和版本恢复。`HeadObject` 等只读元数据操作不会刷新,后台 Uploader 读取也不会让条目看起来刚被使用。远端内容完整回填后,会为恢复的缓存重新开始计算 LRU 时间。

同一版本的重复读取会合并访问时间更新,每分钟最多写一次数据库。访问时间按 best-effort 方式记录,更新失败不会让原本成功的读取失败。
Expand Down
4 changes: 2 additions & 2 deletions docs/zh/reference/s3-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ SynapS3 主要支持 path-style S3 访问,负责把存储桶和对象数据写
| Ownership Controls | `PutBucketOwnershipControls` | 部分支持 | 只接受 `BucketOwnerPreferred`,拒绝其他 ownership modes。 |
| Ownership Controls | `DeleteBucketOwnershipControls` | 部分支持 | 保持 ACL 兼容的 `BucketOwnerPreferred` 行为。 |
| 对象 | `PutObject` | 支持 | 按缓存优先的写入模型存储对象。 |
| 对象 | `GetObject` | 支持 | 从缓存或已提交的远端存储读取。 |
| 对象 | `HeadObject` | 支持 | 读取对象元数据。 |
| 对象 | `GetObject` | 支持 | 从缓存或已提交的远端存储读取,支持单段字节 Range,并返回指定版本的自定义元数据和 `Last-Modified`。冷缓存远端 Range 仍需完整下载来源以校验内容。 |
| 对象 | `HeadObject` | 支持 | 读取对象元数据,包括指定版本的自定义元数据。 |
| 对象 | `DeleteObject` | 支持 | 不带 `versionId` 时创建 delete marker;带 `versionId` 时删除符合条件的数据版本或 delete marker。 |
| 对象 | `DeleteObjects` | 支持 | 对每个条目应用相同的版本删除规则,并分别返回失败结果。 |
| 对象 | `CopyObject` | 支持 | 源对象必须可从缓存或已提交的远端存储读取。 |
Expand Down
74 changes: 74 additions & 0 deletions internal/backend/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"testing"
"time"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/ipfs/go-cid"
Expand Down Expand Up @@ -259,6 +260,79 @@ func TestIntegration_ColdReadAfterEviction(t *testing.T) {
}
}

func TestIntegration_ColdRangeRehydratesOnlyAfterCompleteRead(t *testing.T) {
ib := newIntegrationBackend(t)
ctx := t.Context()
bucket := testutil.SeedBucket(t, ib.db, "range-remote-bucket")
content := strings.Repeat("remote range content", 12)
putObject(t, ib.backend, bucket.Name, "range.bin", content)
version, err := ib.repos.Objects.GetCurrentVersionByBucketAndKey(ctx, bucket.ID, "range.bin")
if err != nil || version == nil {
t.Fatalf("version = %#v, %v", version, err)
}
pieceCID := buildDummyCID(t)
acceptBackendVersionUpload(t, ib.db, ib.repos, version.VersionID, pieceCID, "https://provider.example/range")
cacheKey := version.CacheKey()
evict := func() {
t.Helper()
if err := ib.repos.Objects.ClearContentCachePresence(ctx, *version.ContentID); err != nil {
t.Fatal(err)
}
if err := ib.cache.Delete(ctx, bucket.Name, cacheKey); err != nil {
t.Fatal(err)
}
}
evict()
readBytes := 0
ib.storage.DownloadFunc = func(_ context.Context, _ cid.Cid, _ *storage.DownloadOptions) (io.ReadCloser, error) {
return io.NopCloser(&countingReader{Reader: strings.NewReader(content), count: &readBytes}), nil
}
getRange := func() *s3.GetObjectOutput {
t.Helper()
out, err := ib.backend.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucket.Name), Key: aws.String("range.bin"), Range: aws.String("bytes=2-5"),
})
if err != nil {
t.Fatal(err)
}
return out
}
out := getRange()
got, err := io.ReadAll(out.Body)
if closeErr := out.Body.Close(); closeErr != nil {
t.Fatal(closeErr)
}
if err != nil || string(got) != content[2:6] || readBytes != len(content) {
t.Fatalf("cold range = %q, %v; source read %d/%d bytes", got, err, readBytes, len(content))
}
for attempt := 0; attempt < 200 && !ib.cache.Exists(ctx, bucket.Name, cacheKey); attempt++ {
time.Sleep(10 * time.Millisecond)
}
if !ib.cache.Exists(ctx, bucket.Name, cacheKey) {
t.Fatal("complete cold range did not rehydrate cache")
}

evict()
out = getRange()
if err := out.Body.Close(); err != nil {
t.Fatal(err)
}
if ib.cache.Exists(ctx, bucket.Name, cacheKey) {
t.Fatal("early close committed an incomplete cache entry")
}
}

type countingReader struct {
io.Reader
count *int
}

func (r *countingReader) Read(p []byte) (int, error) {
n, err := r.Reader.Read(p)
*r.count += n
return n, err
}

func TestIntegration_MultipartUpload_Abort(t *testing.T) {
ib := newIntegrationBackend(t)
ctx := context.Background()
Expand Down
4 changes: 3 additions & 1 deletion internal/backend/multipart.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ func (b *SynapseBackend) UploadPartCopy(ctx context.Context, input *s3.UploadPar
if partNum < 1 || partNum > 10000 {
return s3response.CopyPartResult{}, s3err.GetInvalidArgumentErr(s3err.InvalidArgPartNumber, fmt.Sprint(*input.PartNumber))
}
if input.CopySourceRange != nil && *input.CopySourceRange != "" {
return s3response.CopyPartResult{}, s3err.GetAPIError(s3err.ErrNotImplemented)
}

// Parse and validate source object
srcBucketName, srcKey, srcVersionID, err := parseCopySource(*input.CopySource)
Expand All @@ -154,7 +157,6 @@ func (b *SynapseBackend) UploadPartCopy(ctx context.Context, input *s3.UploadPar
}
defer func() { _ = srcResult.Body.Close() }()

// NOTE: CopySourceRange for partial copies is not yet supported (future enhancement).
cacheInfo, err := b.cache.PutPart(ctx, *input.UploadId, partNum, objectlimits.LimitFOCUploadReader(srcResult.Body))
if err != nil {
if errors.Is(err, objectlimits.ErrTooLarge) {
Expand Down
29 changes: 27 additions & 2 deletions internal/backend/object.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/strahe/synaps3/internal/storagecleanup"
"github.com/strahe/synaps3/internal/storagepipeline"
taskengine "github.com/strahe/synaps3/internal/task"
versitybackend "github.com/versity/versitygw/backend"
"github.com/versity/versitygw/s3err"
"github.com/versity/versitygw/s3response"
)
Expand Down Expand Up @@ -200,15 +201,36 @@ func (b *SynapseBackend) GetObject(ctx context.Context, input *s3.GetObjectInput
admin.CacheHitsTotal.Inc()
}

admin.ObjectOperationsTotal.WithLabelValues("get", "success").Inc()
etag := fmt.Sprintf(`"%s"`, out.ETag)
contentType := out.ContentType
acceptRanges := "bytes"
length := out.Size
var contentRange *string
if input.Range != nil && *input.Range != "" {
start, count, valid, rangeErr := versitybackend.ParseObjectRange(out.Size, *input.Range)
if rangeErr != nil {
_ = out.Body.Close()
admin.ObjectOperationsTotal.WithLabelValues("get", "failure").Inc()
return nil, rangeErr
}
if valid {
out.Body = newRangeReadCloser(out.Body, start, count, out.Source == objectreader.SourceProvider)
length = count
rangeValue := fmt.Sprintf("bytes %d-%d/%d", start, start+count-1, out.Size)
contentRange = &rangeValue
}
}
admin.ObjectOperationsTotal.WithLabelValues("get", "success").Inc()
return &s3.GetObjectOutput{
Body: out.Body,
ContentLength: &out.Size,
ContentLength: &length,
ContentRange: contentRange,
AcceptRanges: &acceptRanges,
ETag: &etag,
ContentType: &contentType,
VersionId: &out.VersionID,
LastModified: &out.LastModified,
Metadata: out.Metadata,
}, nil
}

Expand Down Expand Up @@ -239,6 +261,7 @@ func (b *SynapseBackend) HeadObject(ctx context.Context, input *s3.HeadObjectInp
ContentType: &meta.ContentType,
LastModified: &meta.LastModified,
VersionId: &meta.VersionID,
Metadata: meta.Metadata,
}, nil
}

Expand Down Expand Up @@ -1074,6 +1097,7 @@ type objectMetadataResult struct {
VersionID string
MultipartUploadID *string
LastModified time.Time
Metadata map[string]string
}

func (b *SynapseBackend) objectMetadata(ctx context.Context, bucketID int64, key, versionID string) (objectMetadataResult, error) {
Expand All @@ -1090,6 +1114,7 @@ func (b *SynapseBackend) objectMetadata(ctx context.Context, bucketID int64, key
VersionID: version.VersionID,
MultipartUploadID: version.MultipartUploadID,
LastModified: version.CreatedAt,
Metadata: maps.Clone(version.Metadata),
}, nil
}

Expand Down
Loading
Loading