mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-12 17:27:50 +02:00
* filer.sync: sign proxied chunk I/O from the per-side security file The -a.security / -b.security files were used for gRPC TLS and the HTTPS client but not for jwt.filer_signing, so filer-proxied chunk reads and writes carried a token signed with the process-wide key and failed authorization whenever the two clusters' keys differ. LoadFilerJwtFromFile returns a FilerJwtProvider for each side's file, which FilerSource and FilerSink now accept for proxied chunk reads and writes. With no keys in the file or no flag, both fall back to the process-wide jwt.filer_signing configuration as before. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * replication: use the side filer read key for manifest downloads and fall back per access level Manifest chunk resolution still signed proxied downloads with the process-wide read key, so a source filer requiring its own key 401'd on manifest-bearing files. A side security file that set only one access level also produced empty tokens for the other instead of inheriting the process-wide key, and the side file loader ignored the WEED_ environment overrides the filer itself honors. ResolveChunkManifest/ResolveOneChunkManifest keep their signatures; FilerJwt-aware variants thread the provider down to fetchWholeChunk, which prefers it on proxy URLs. The side loader now applies the same environment precedence and falls back to the process-wide signer per missing access level. * security: verify the configured filer token lifetimes * security: reject negative filer token lifetimes A negative expires_after_seconds reached GenJwtForFilerServer and produced a token with no expiration claim. Also synchronize the Authorization-header capture in the proxy test and restore the prior viper key on cleanup. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
306 lines
11 KiB
Go
306 lines
11 KiB
Go
package filer
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
func newManifestCacheTestChunk(fileID string) *filer_pb.FileChunk {
|
|
return resolveTestManifest(fileID, 0)
|
|
}
|
|
|
|
func newTestManifestCache(t testing.TB) *ChunkManifestCache {
|
|
t.Helper()
|
|
cache := NewChunkManifestCache(MaxMountChunkManifestCacheEntries, MaxMountChunkManifestCacheBytes)
|
|
t.Cleanup(cache.clear)
|
|
return cache
|
|
}
|
|
|
|
func TestChunkGroupManifestResolutionCachesRepeatedOpens(t *testing.T) {
|
|
cache := newTestManifestCache(t)
|
|
const manifestID = "cache-repeated-open"
|
|
fixture := newManifestReadFixture(t, map[string][]*filer_pb.FileChunk{
|
|
manifestID: {resolveTestData("cached-data", 0)},
|
|
}, nil)
|
|
var lookups atomic.Int32
|
|
lookup := func(ctx context.Context, fileID string) ([]string, error) {
|
|
lookups.Add(1)
|
|
return fixture.lookup(ctx, fileID)
|
|
}
|
|
|
|
for i := 0; i < 3; i++ {
|
|
_, err := NewChunkGroup(lookup, nil, []*filer_pb.FileChunk{
|
|
newManifestCacheTestChunk(manifestID),
|
|
}, 1, nil, cache)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
require.Equal(t, int32(1), lookups.Load(), "repeated Mount opens should reuse the Manifest lookup")
|
|
require.Equal(t, int32(1), fixture.loads.Load(), "repeated Mount opens should reuse the Manifest fetch")
|
|
}
|
|
|
|
func TestChunkGroupManifestResolutionDoesNotCacheFailedReads(t *testing.T) {
|
|
cache := newTestManifestCache(t)
|
|
const manifestID = "cache-failed-read"
|
|
fixture := newManifestReadFixture(t, nil, nil)
|
|
var lookups atomic.Int32
|
|
lookup := func(ctx context.Context, fileID string) ([]string, error) {
|
|
lookups.Add(1)
|
|
return fixture.lookup(ctx, fileID)
|
|
}
|
|
chunk := newManifestCacheTestChunk(manifestID)
|
|
|
|
_, err := NewChunkGroup(lookup, nil, []*filer_pb.FileChunk{chunk}, 1, nil, cache)
|
|
require.Error(t, err)
|
|
|
|
fixture.manifests[manifestID] = manifestBytes(t, resolveTestData("retried-data", 0))
|
|
_, err = NewChunkGroup(lookup, nil, []*filer_pb.FileChunk{chunk}, 1, nil, cache)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, int32(2), lookups.Load(), "a failed lookup must not poison later Mount opens")
|
|
require.Equal(t, int32(2), fixture.loads.Load(), "a failed fetch must not poison later Mount opens")
|
|
}
|
|
|
|
func TestChunkGroupManifestResolutionConcurrentWarmOpensReuseFetch(t *testing.T) {
|
|
cache := newTestManifestCache(t)
|
|
const manifestID = "cache-concurrent-warm-open"
|
|
fixture := newManifestReadFixture(t, map[string][]*filer_pb.FileChunk{
|
|
manifestID: {resolveTestData("concurrent-data", 0)},
|
|
}, nil)
|
|
var lookups atomic.Int32
|
|
lookup := func(ctx context.Context, fileID string) ([]string, error) {
|
|
lookups.Add(1)
|
|
return fixture.lookup(ctx, fileID)
|
|
}
|
|
|
|
_, err := NewChunkGroup(lookup, nil, []*filer_pb.FileChunk{
|
|
newManifestCacheTestChunk(manifestID),
|
|
}, 1, nil, cache)
|
|
require.NoError(t, err)
|
|
|
|
const concurrentOpens = 8
|
|
var wg sync.WaitGroup
|
|
errs := make(chan error, concurrentOpens)
|
|
for i := 0; i < concurrentOpens; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
_, openErr := NewChunkGroup(lookup, nil, []*filer_pb.FileChunk{
|
|
newManifestCacheTestChunk(manifestID),
|
|
}, 1, nil, cache)
|
|
errs <- openErr
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(errs)
|
|
for openErr := range errs {
|
|
require.NoError(t, openErr)
|
|
}
|
|
|
|
require.Equal(t, int32(1), lookups.Load(), "warm concurrent Mount opens should not repeat the lookup")
|
|
require.Equal(t, int32(1), fixture.loads.Load(), "warm concurrent Mount opens should not repeat the fetch")
|
|
}
|
|
|
|
func TestChunkGroupManifestResolutionCoalescesColdMisses(t *testing.T) {
|
|
cache := newTestManifestCache(t)
|
|
const manifestID = "cache-cold-miss"
|
|
// Delay the manifest response so the leader's fetch is still in flight
|
|
// when the other concurrent opens arrive and join the singleflight.
|
|
fixture := newManifestReadFixture(t, map[string][]*filer_pb.FileChunk{
|
|
manifestID: {resolveTestData("cold-data", 0)},
|
|
}, map[string]time.Duration{manifestID: 50 * time.Millisecond})
|
|
var lookups atomic.Int32
|
|
lookup := func(ctx context.Context, fileID string) ([]string, error) {
|
|
lookups.Add(1)
|
|
return fixture.lookup(ctx, fileID)
|
|
}
|
|
|
|
const concurrentOpens = 8
|
|
var wg sync.WaitGroup
|
|
errs := make(chan error, concurrentOpens)
|
|
for i := 0; i < concurrentOpens; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
_, openErr := NewChunkGroup(lookup, nil, []*filer_pb.FileChunk{
|
|
newManifestCacheTestChunk(manifestID),
|
|
}, 1, nil, cache)
|
|
errs <- openErr
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(errs)
|
|
for openErr := range errs {
|
|
require.NoError(t, openErr)
|
|
}
|
|
|
|
require.Equal(t, int32(1), lookups.Load(), "concurrent cold opens should coalesce into a single lookup")
|
|
require.Equal(t, int32(1), fixture.loads.Load(), "concurrent cold opens should coalesce into a single fetch")
|
|
}
|
|
|
|
func TestResolveOneChunkManifestDoesNotUseMountCache(t *testing.T) {
|
|
cache := newTestManifestCache(t)
|
|
const manifestID = "cache-public-resolver"
|
|
fixture := newManifestReadFixture(t, map[string][]*filer_pb.FileChunk{
|
|
manifestID: {resolveTestData("public-resolver-data", 0)},
|
|
}, nil)
|
|
var lookups atomic.Int32
|
|
lookup := func(ctx context.Context, fileID string) ([]string, error) {
|
|
lookups.Add(1)
|
|
return fixture.lookup(ctx, fileID)
|
|
}
|
|
chunk := newManifestCacheTestChunk(manifestID)
|
|
|
|
for i := 0; i < 2; i++ {
|
|
_, err := ResolveOneChunkManifest(context.Background(), lookup, chunk, nil)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
require.Equal(t, int32(2), lookups.Load(), "the general resolver must retain its uncached behavior")
|
|
require.Equal(t, int32(2), fixture.loads.Load(), "the general resolver must not use the Mount cache")
|
|
|
|
// The shared cache must remain empty: a nil-cache caller must not pollute
|
|
// a mount-owned cache, and vice versa.
|
|
_, found := cache.get(chunkManifestCacheKey{fileID: manifestID})
|
|
require.False(t, found, "a nil-cache resolve must not populate an unrelated cache")
|
|
}
|
|
|
|
func TestChunkGroupManifestResolutionDoesNotCacheMalformedManifest(t *testing.T) {
|
|
cache := newTestManifestCache(t)
|
|
const manifestID = "cache-malformed-manifest"
|
|
fixture := newManifestReadFixture(t, map[string][]*filer_pb.FileChunk{
|
|
manifestID: nil,
|
|
}, nil)
|
|
fixture.manifests[manifestID] = []byte("malformed manifest")
|
|
chunk := newManifestCacheTestChunk(manifestID)
|
|
|
|
_, err := NewChunkGroup(fixture.lookup, nil, []*filer_pb.FileChunk{chunk}, 1, nil, cache)
|
|
require.Error(t, err)
|
|
|
|
fixture.manifests[manifestID] = manifestBytes(t, resolveTestData("after-malformed-data", 0))
|
|
_, err = NewChunkGroup(fixture.lookup, nil, []*filer_pb.FileChunk{chunk}, 1, nil, cache)
|
|
require.NoError(t, err)
|
|
require.Equal(t, int32(2), fixture.loads.Load(), "a malformed manifest must not poison later Mount opens")
|
|
}
|
|
|
|
func TestChunkManifestCacheSeparatesReadParameters(t *testing.T) {
|
|
cache := NewChunkManifestCache(4, 1024)
|
|
base := chunkManifestCacheKey{fileID: "same-file"}
|
|
cache.put(base, []byte("manifest"))
|
|
|
|
_, found := cache.get(chunkManifestCacheKey{fileID: "same-file", cipherKey: "key"})
|
|
require.False(t, found, "cipher keys must be part of the cache key")
|
|
_, found = cache.get(chunkManifestCacheKey{fileID: "same-file", isCompressed: true})
|
|
require.False(t, found, "compression settings must be part of the cache key")
|
|
_, found = cache.get(chunkManifestCacheKey{fileID: "different-file"})
|
|
require.False(t, found, "FileIds must be part of the cache key")
|
|
}
|
|
|
|
func TestChunkManifestCacheCopiesData(t *testing.T) {
|
|
cache := NewChunkManifestCache(1, 1024)
|
|
key := chunkManifestCacheKey{fileID: "copy-data"}
|
|
original := []byte("manifest")
|
|
cache.put(key, original)
|
|
original[0] = 'X'
|
|
|
|
data, found := cache.get(key)
|
|
require.True(t, found)
|
|
require.Equal(t, []byte("manifest"), data)
|
|
data[0] = 'Y'
|
|
|
|
data, found = cache.get(key)
|
|
require.True(t, found)
|
|
require.Equal(t, []byte("manifest"), data)
|
|
}
|
|
|
|
func TestChunkManifestCacheEvictsLeastRecentlyUsedAndOversizedEntries(t *testing.T) {
|
|
cache := NewChunkManifestCache(2, 6)
|
|
first := chunkManifestCacheKey{fileID: "first"}
|
|
second := chunkManifestCacheKey{fileID: "second"}
|
|
third := chunkManifestCacheKey{fileID: "third"}
|
|
oversized := chunkManifestCacheKey{fileID: "oversized"}
|
|
|
|
cache.put(first, []byte("one"))
|
|
cache.put(second, []byte("two"))
|
|
_, found := cache.get(first)
|
|
require.True(t, found, "the first entry should be present before eviction")
|
|
cache.put(third, []byte("tri"))
|
|
|
|
_, found = cache.get(first)
|
|
require.True(t, found, "a recent entry should survive LRU eviction")
|
|
_, found = cache.get(second)
|
|
require.False(t, found, "the least recently used entry should be evicted")
|
|
_, found = cache.get(third)
|
|
require.True(t, found)
|
|
|
|
cache.put(oversized, []byte("too large"))
|
|
_, found = cache.get(oversized)
|
|
require.False(t, found, "an entry over the byte limit must not be cached")
|
|
}
|
|
|
|
func TestResolveOneChunkManifestHonorsCanceledContextOnCacheHit(t *testing.T) {
|
|
cache := NewChunkManifestCache(1, 1024)
|
|
chunk := newManifestCacheTestChunk("cache-canceled-hit")
|
|
cache.put(chunkManifestCacheKey{fileID: chunk.GetFileIdString()}, manifestBytes(t, resolveTestData("canceled-data", 0)))
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
lookupCalled := false
|
|
lookup := func(context.Context, string) ([]string, error) {
|
|
lookupCalled = true
|
|
return nil, errors.New("lookup should not be called")
|
|
}
|
|
|
|
_, err := resolveOneChunkManifest(ctx, lookup, chunk, nil, cache, nil)
|
|
require.ErrorIs(t, err, context.Canceled)
|
|
require.False(t, lookupCalled, "a canceled cache hit must not issue a lookup")
|
|
}
|
|
|
|
func TestResolveOneChunkManifestCanceledWaiterReturnsDuringCoalescedMiss(t *testing.T) {
|
|
cache := newTestManifestCache(t)
|
|
const manifestID = "cache-canceled-waiter"
|
|
// Delay the manifest response so the leader's fetch is still in flight
|
|
// when the waiter arrives and cancels.
|
|
fixture := newManifestReadFixture(t, map[string][]*filer_pb.FileChunk{
|
|
manifestID: {resolveTestData("canceled-waiter-data", 0)},
|
|
}, map[string]time.Duration{manifestID: 100 * time.Millisecond})
|
|
chunk := newManifestCacheTestChunk(manifestID)
|
|
|
|
// Leader starts the fetch with a live context.
|
|
leaderCtx, leaderCancel := context.WithCancel(context.Background())
|
|
defer leaderCancel()
|
|
leaderDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := resolveOneChunkManifest(leaderCtx, fixture.lookup, chunk, nil, cache, nil)
|
|
leaderDone <- err
|
|
}()
|
|
|
|
// Waiter cancels its own context while the leader's fetch is still in
|
|
// flight; it must return context.Canceled promptly instead of blocking
|
|
// for the leader's result.
|
|
waiterCtx, waiterCancel := context.WithCancel(context.Background())
|
|
waiterCancel()
|
|
_, err := resolveOneChunkManifest(waiterCtx, fixture.lookup, chunk, nil, cache, nil)
|
|
require.ErrorIs(t, err, context.Canceled)
|
|
|
|
// The leader must still complete successfully and populate the cache.
|
|
require.NoError(t, <-leaderDone)
|
|
data, found := cache.get(chunkManifestCacheKey{fileID: manifestID})
|
|
require.True(t, found, "the leader's successful fetch must populate the cache")
|
|
require.NotEmpty(t, data)
|
|
}
|
|
|
|
func manifestBytes(t testing.TB, chunks ...*filer_pb.FileChunk) []byte {
|
|
t.Helper()
|
|
data, err := proto.Marshal(&filer_pb.FileChunkManifest{Chunks: chunks})
|
|
require.NoError(t, err)
|
|
return data
|
|
}
|