Files
bucketeer/internal/pipeline/pipeline_test.go
T
StasonJatham 9710efec1a fix memory blowup and keep direct matches from being dropped
- dns.TTLCache: stop preallocating every shard map to maxShard; this
  allocated the full theoretical cache capacity up front (hundreds of MB
  of empty buckets) and pushed the container over its memory limit,
  causing an OOM restart loop under Docker's default 1g cap.
- pipeline: write direct provider matches immediately without a blocking
  DNS enrichment step, so they can no longer be starved by the optional
  CNAME queue. Add TestDirectNotStarvedByDNSBacklog.
- config: add DEDUP_SIZE, lower DNS_CACHE_SIZE/DEDUP_SIZE defaults to 256k.
- .env.example / README: document DEDUP_SIZE and cache sizing.

Live: stable at ~250-300 MiB with no restarts, direct queue draining to 0.
2026-09-22 13:33:05 +02:00

292 lines
7.7 KiB
Go

package pipeline
import (
"context"
"net"
"strconv"
"sync"
"testing"
"time"
"software.sslmate.com/src/certspotter"
"tea.karlcom.de/karl/bucketeer/internal/dns"
"tea.karlcom.de/karl/bucketeer/internal/metrics"
"tea.karlcom.de/karl/bucketeer/internal/model"
)
func makeHosts(prefix, suffix string) []string {
hosts := make([]string, 256)
for i := range hosts {
hosts[i] = prefix + strconv.Itoa(i) + suffix
}
return hosts
}
// TestHandleHostNoAllocs audits the sink hot path: after warmup, classifying and
// enqueueing a hostname must not allocate.
func TestHandleHostNoAllocs(t *testing.T) {
p := New(Config{DNSQueueSize: 1 << 20, DedupSize: 1 << 20})
nonMatch := makeHosts("api", ".example.com")
direct := makeHosts("bucket", ".s3.eu-central-1.amazonaws.com")
for _, h := range nonMatch {
p.HandleHost(h)
}
for _, h := range direct {
p.HandleHost(h)
}
allocs := testing.AllocsPerRun(2000, func() {
for i := 0; i < 64; i++ {
p.HandleHost(nonMatch[i])
p.HandleHost(direct[i])
}
})
if allocs != 0 {
t.Fatalf("HandleHost allocates %.3f allocs/op, want 0", allocs)
}
}
func BenchmarkHandleHostNonMatch(b *testing.B) {
p := New(Config{DNSQueueSize: 1 << 20, DedupSize: 1 << 20})
hosts := makeHosts("api", ".example.com")
for _, h := range hosts {
p.HandleHost(h)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
p.HandleHost(hosts[i&255])
}
}
func BenchmarkHandleHostDirectMatch(b *testing.B) {
p := New(Config{DirectQueueSize: 1 << 20, DedupSize: 1 << 20})
hosts := makeHosts("bucket", ".s3.eu-central-1.amazonaws.com")
for _, h := range hosts {
p.HandleHost(h)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
p.HandleHost(hosts[i&255])
}
}
type fakeResolver struct {
cnames map[string]string
ips map[string][]net.IP
}
func (f *fakeResolver) LookupCNAME(ctx context.Context, host string) (string, error) {
if c, ok := f.cnames[host]; ok {
return c, nil
}
return host, nil
}
func (f *fakeResolver) LookupIP(ctx context.Context, host string) ([]net.IP, error) {
return f.ips[host], nil
}
func newEnricher(r dns.Resolver) *dns.Enricher {
return &dns.Enricher{
Resolver: r,
Cache: dns.NewTTLCache[dns.Result](1024, 8),
PositiveTTL: time.Minute,
NegativeTTL: time.Minute,
MaxDepth: 8,
}
}
type collector struct {
mu sync.Mutex
items []Finding
}
func (c *collector) write(ctx context.Context, batch []Finding) error {
c.mu.Lock()
defer c.mu.Unlock()
c.items = append(c.items, batch...)
return nil
}
func (c *collector) all() []Finding {
c.mu.Lock()
defer c.mu.Unlock()
return append([]Finding(nil), c.items...)
}
func TestDirectMatch(t *testing.T) {
col := &collector{}
m := metrics.New()
p := New(Config{
Enricher: newEnricher(&fakeResolver{}),
Write: col.write,
Metrics: m,
BatchSize: 10,
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p.Start(ctx)
p.HandleIdentifiers(ctx, &certspotter.Identifiers{DNSNames: []string{"bucket.s3.eu-central-1.amazonaws.com"}})
time.Sleep(50 * time.Millisecond)
p.Close()
items := col.all()
if len(items) != 1 {
t.Fatalf("got %d findings, want 1 (%+v)", len(items), items)
}
f := items[0]
if f.Provider != model.ProviderAWS || f.Service != model.ServiceS3 || f.Source != model.SourceDirectCT {
t.Fatalf("unexpected finding %+v", f)
}
if m.DirectMatches.Load() != 1 {
t.Fatalf("direct matches = %d, want 1", m.DirectMatches.Load())
}
}
func TestCNAMEMatch(t *testing.T) {
col := &collector{}
r := &fakeResolver{
cnames: map[string]string{"files.example.com": "files.s3.amazonaws.com"},
ips: map[string][]net.IP{"files.example.com": {net.ParseIP("1.2.3.4")}},
}
m := metrics.New()
p := New(Config{Enricher: newEnricher(r), Write: col.write, Metrics: m, BatchSize: 10})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p.Start(ctx)
p.HandleIdentifiers(ctx, &certspotter.Identifiers{DNSNames: []string{"files.example.com"}})
time.Sleep(50 * time.Millisecond)
p.Close()
items := col.all()
if len(items) != 1 {
t.Fatalf("got %d findings, want 1", len(items))
}
f := items[0]
if f.Source != model.SourceCNAME || f.Hostname != "files.s3.amazonaws.com" || f.Original != "files.example.com" {
t.Fatalf("unexpected finding %+v", f)
}
if len(f.IPv4) != 1 || f.IPv4[0] != "1.2.3.4" {
t.Fatalf("unexpected IPs %+v", f.IPv4)
}
if m.CNAMEMatches.Load() != 1 {
t.Fatalf("cname matches = %d, want 1", m.CNAMEMatches.Load())
}
}
func TestCNAMEMatchIPv6(t *testing.T) {
col := &collector{}
r := &fakeResolver{
cnames: map[string]string{"db.example.com": "mydb.cluster-abc.us-east-1.rds.amazonaws.com"},
ips: map[string][]net.IP{"db.example.com": {net.ParseIP("2606:4700::1111")}},
}
p := New(Config{Enricher: newEnricher(r), Write: col.write, BatchSize: 10})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p.Start(ctx)
p.HandleHost("db.example.com")
time.Sleep(50 * time.Millisecond)
p.Close()
items := col.all()
if len(items) != 1 || len(items[0].IPv6) != 1 || items[0].IPv6[0] != "2606:4700::1111" {
t.Fatalf("unexpected IPv6 finding: %+v", items)
}
}
type blockingResolver struct{ release chan struct{} }
func (b *blockingResolver) LookupCNAME(ctx context.Context, host string) (string, error) {
select {
case <-b.release:
case <-ctx.Done():
}
return host, nil
}
func (b *blockingResolver) LookupIP(ctx context.Context, host string) ([]net.IP, error) {
return nil, nil
}
// TestDirectNotStarvedByDNSBacklog verifies direct provider matches are written
// even while the DNS queue is saturated with optional CNAME work.
func TestDirectNotStarvedByDNSBacklog(t *testing.T) {
col := &collector{}
release := make(chan struct{})
p := New(Config{
DNSQueueSize: 1,
DNSWorkers: 1,
Enricher: newEnricher(&blockingResolver{release: release}),
Write: col.write,
BatchSize: 10,
FlushInterval: 10 * time.Millisecond,
})
ctx, cancel := context.WithCancel(context.Background())
p.Start(ctx)
for i := 0; i < 100; i++ {
p.HandleHost("api" + strconv.Itoa(i) + ".example.com") // fills/blocks DNS queue
}
p.HandleHost("bucket.s3.eu-central-1.amazonaws.com")
found := false
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
for _, f := range col.all() {
if f.Hostname == "bucket.s3.eu-central-1.amazonaws.com" {
found = true
}
}
if found {
break
}
time.Sleep(5 * time.Millisecond)
}
close(release)
cancel()
p.Close()
if !found {
t.Fatal("direct match was not written while the DNS queue was saturated")
}
}
func TestQueueSaturationDrops(t *testing.T) {
m := metrics.New()
p := New(Config{DirectQueueSize: 1, DNSQueueSize: 1, Metrics: m})
// Intentionally do not Start: the queue stays full.
p.HandleHost("bucket.s3.amazonaws.com")
p.HandleHost("other.s3.amazonaws.com")
if m.QueueDrops.Load() == 0 {
t.Fatal("expected a drop when the direct queue is full")
}
if m.Candidates.Load() != 2 {
t.Fatalf("candidates = %d, want 2", m.Candidates.Load())
}
}
func TestDNSDedup(t *testing.T) {
m := metrics.New()
p := New(Config{DNSQueueSize: 8, Metrics: m})
p.HandleHost("files.example.com")
p.HandleHost("files.example.com")
if got := len(p.dnsQ); got != 1 {
t.Fatalf("dns queue len = %d, want 1", got)
}
}
func TestGenericEndpointNotStored(t *testing.T) {
col := &collector{}
p := New(Config{Write: col.write, BatchSize: 10})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p.Start(ctx)
p.HandleIdentifiers(ctx, &certspotter.Identifiers{DNSNames: []string{
"objectstorage.us-ashburn-1.oraclecloud.com",
}})
time.Sleep(30 * time.Millisecond)
p.Close()
if items := col.all(); len(items) != 0 {
t.Fatalf("generic endpoint produced findings: %+v", items)
}
}