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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
# Added by goreleaser init:
dist/
cache/
2 changes: 1 addition & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ linters:
- bodyclose # checks whether HTTP response body is closed successfully
- canonicalheader # checks whether net/http.Header uses canonical header
- copyloopvar # detects places where loop variables are copied (Go 1.22+)
- cyclop # checks function and package cyclomatic complexity
# - cyclop # checks function and package cyclomatic complexity
- depguard # checks if package imports are in a list of acceptable packages
- dupl # tool for code clone detection
- durationcheck # checks for two durations multiplied together
Expand Down
30 changes: 26 additions & 4 deletions cmd/sfptcd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,43 @@ package main

import (
"context"
"log/slog"
"net/http"
"os"
"time"

"github.com/alecthomas/kong"

"github.com/block/sfptc/internal/config"
"github.com/block/sfptc/internal/logging"
)

var cli struct {
logging.Config `prefix:"log-"`
Config *os.File `hcl:"-" help:"Configuration file path." placeholder:"PATH" required:""`
Bind string `hcl:"bind" default:"127.0.0.1:8080" help:"Bind address for the server."`
LoggingConfig logging.Config `embed:"" prefix:"log-"`
}

func main() {
kong.Parse(&cli)
kctx := kong.Parse(&cli)

ctx := context.Background()
logger, ctx := logging.Configure(ctx, cli.Config)
logger, ctx := logging.Configure(ctx, cli.LoggingConfig)

logger.InfoContext(ctx, "Starting sfptcd")
mux := http.NewServeMux()

err := config.Load(ctx, cli.Config, mux)
kctx.FatalIfErrorf(err)

logger.InfoContext(ctx, "Starting sfptcd", slog.String("bind", cli.Bind))

server := &http.Server{
Addr: cli.Bind,
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
ReadHeaderTimeout: 10 * time.Second,
}
err = server.ListenAndServe()
kctx.FatalIfErrorf(err)
}
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/block/sfptc
go 1.25.5

require (
github.com/alecthomas/hcl/v2 v2.3.0
github.com/alecthomas/hcl/v2 v2.3.1
github.com/alecthomas/kong v1.13.0
github.com/lmittmann/tint v1.1.2
)
Expand All @@ -15,7 +15,7 @@ require (

require (
github.com/alecthomas/assert/v2 v2.11.0
github.com/alecthomas/errors v0.8.3
github.com/alecthomas/errors v0.9.1
github.com/alecthomas/participle/v2 v2.1.4 // indirect
github.com/alecthomas/repr v0.5.2 // indirect
github.com/pkg/xattr v0.4.12
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/errors v0.8.3 h1:IPyQj2fU3GGsl6C/r4OPmYgqgNSDLWJLE/ln2fLjwas=
github.com/alecthomas/errors v0.8.3/go.mod h1:l8mjMEHMGUdIWPMNtvDyRYPVS1fQFXHFXc/iVCCLGkI=
github.com/alecthomas/hcl/v2 v2.3.0 h1:voBoBfb69MBRFkJ5NyMN/cSFfevVZKJIoxwfuJ1j2gU=
github.com/alecthomas/hcl/v2 v2.3.0/go.mod h1:4UUp66q8ony5j8tm2bANErujUpZ3GgHBLgaKxTUQlQI=
github.com/alecthomas/errors v0.9.1 h1:JNXtU30rtMNARCkW41OTZ4yL6Lyocq20xIJgIw2raqI=
github.com/alecthomas/errors v0.9.1/go.mod h1:l8mjMEHMGUdIWPMNtvDyRYPVS1fQFXHFXc/iVCCLGkI=
github.com/alecthomas/hcl/v2 v2.3.1 h1:Nkj0svGJawz920nQyWUhD2PYmD47p7BB9vc2e3kft1o=
github.com/alecthomas/hcl/v2 v2.3.1/go.mod h1:4UUp66q8ony5j8tm2bANErujUpZ3GgHBLgaKxTUQlQI=
github.com/alecthomas/kong v1.13.0 h1:5e/7XC3ugvhP1DQBmTS+WuHtCbcv44hsohMgcvVxSrA=
github.com/alecthomas/kong v1.13.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I=
github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U=
Expand Down
21 changes: 18 additions & 3 deletions internal/cache/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,33 @@ import (
"github.com/alecthomas/hcl/v2"
)

var registry = map[string]func(config *hcl.Block) (Cache, error){}
// ErrNotFound is returned when a cache backend is not found.
var ErrNotFound = errors.New("cache backend not found")

var registry = map[string]func(ctx context.Context, config *hcl.Block) (Cache, error){}

// Factory is a function that creates a new cache instance from the given hcl-tagged configuration struct.
type Factory[Config any, C Cache] func(ctx context.Context, config Config) (C, error)

// Register a cache factory function.
func Register[Config any, C Cache](id string, factory Factory[Config, C]) {
registry[id] = func(config *hcl.Block) (Cache, error) {
registry[id] = func(ctx context.Context, config *hcl.Block) (Cache, error) {
var cfg Config
if err := hcl.UnmarshalBlock(config, &cfg); err != nil {
return nil, errors.WithStack(err)
}
return factory(context.Background(), cfg)
return factory(ctx, cfg)
}
}

// Create a new cache instance from the given name and configuration.
//
// Will return "ErrNotFound" if the cache backend is not found.
func Create(ctx context.Context, name string, config *hcl.Block) (Cache, error) {
if factory, ok := registry[name]; ok {
return errors.WithStack2(factory(ctx, config))
}
return nil, errors.Errorf("%s: %w", name, ErrNotFound)
}

// Key represents a unique identifier for a cached object.
Expand Down Expand Up @@ -59,6 +72,8 @@ func (k *Key) MarshalText() ([]byte, error) {

// A Cache knows how to retrieve, create and delete objects from a cache.
type Cache interface {
// String describes the Cache implementation.
String() string
// Open an existing file in the cache.
//
// Expired files SHOULD not be returned.
Expand Down
2 changes: 2 additions & 0 deletions internal/cache/disk.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ func NewDisk(ctx context.Context, config DiskConfig) (*Disk, error) {
return disk, nil
}

func (d *Disk) String() string { return "disk:" + d.config.Root }

func (d *Disk) Close() error {
d.stop()
return nil
Expand Down
27 changes: 15 additions & 12 deletions internal/cache/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cache
import (
"bytes"
"context"
"fmt"
"io"
"os"
"sync"
Expand All @@ -12,10 +13,10 @@ import (
)

func init() {
Register("memory", NewMemoryCache)
Register("memory", NewMemory)
}

type MemoryCacheConfig struct {
type MemoryConfig struct {
LimitMB int `hcl:"limit-mb,optional" help:"Maximum size of the disk cache in megabytes (defaults to 1GB)." default:"1024"`
MaxTTL time.Duration `hcl:"max-ttl,optional" help:"Maximum time-to-live for entries in the disk cache (defaults to 1 hour)." default:"1h"`
}
Expand All @@ -25,21 +26,23 @@ type memoryEntry struct {
expiresAt time.Time
}

type memoryCache struct {
config MemoryCacheConfig
type Memory struct {
config MemoryConfig
mu sync.RWMutex
entries map[Key]*memoryEntry
currentSize int64
}

func NewMemoryCache(_ context.Context, config MemoryCacheConfig) (Cache, error) {
return &memoryCache{
func NewMemory(_ context.Context, config MemoryConfig) (*Memory, error) {
return &Memory{
config: config,
entries: make(map[Key]*memoryEntry),
}, nil
}

func (m *memoryCache) Open(_ context.Context, key Key) (io.ReadCloser, error) {
func (m *Memory) String() string { return fmt.Sprintf("memory:%dMB", m.config.LimitMB) }

func (m *Memory) Open(_ context.Context, key Key) (io.ReadCloser, error) {
m.mu.RLock()
defer m.mu.RUnlock()

Expand All @@ -55,7 +58,7 @@ func (m *memoryCache) Open(_ context.Context, key Key) (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(entry.data)), nil
}

func (m *memoryCache) Create(_ context.Context, key Key, ttl time.Duration) (io.WriteCloser, error) {
func (m *Memory) Create(_ context.Context, key Key, ttl time.Duration) (io.WriteCloser, error) {
if ttl == 0 {
ttl = m.config.MaxTTL
}
Expand All @@ -70,7 +73,7 @@ func (m *memoryCache) Create(_ context.Context, key Key, ttl time.Duration) (io.
return writer, nil
}

func (m *memoryCache) Delete(_ context.Context, key Key) error {
func (m *Memory) Delete(_ context.Context, key Key) error {
m.mu.Lock()
defer m.mu.Unlock()

Expand All @@ -83,7 +86,7 @@ func (m *memoryCache) Delete(_ context.Context, key Key) error {
return nil
}

func (m *memoryCache) Close() error {
func (m *Memory) Close() error {
m.mu.Lock()
defer m.mu.Unlock()

Expand All @@ -92,7 +95,7 @@ func (m *memoryCache) Close() error {
}

type memoryWriter struct {
cache *memoryCache
cache *Memory
key Key
buf *bytes.Buffer
expiresAt time.Time
Expand Down Expand Up @@ -142,7 +145,7 @@ func (w *memoryWriter) Close() error {
return nil
}

func (m *memoryCache) evictOldest(neededSpace int64) {
func (m *Memory) evictOldest(neededSpace int64) {
type entryInfo struct {
key Key
size int64
Expand Down
2 changes: 1 addition & 1 deletion internal/cache/memory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
func TestMemoryCache(t *testing.T) {
cachetest.Suite(t, func(t *testing.T) cache.Cache {
ctx := t.Context()
c, err := cache.NewMemoryCache(ctx, cache.MemoryCacheConfig{MaxTTL: 100 * time.Millisecond})
c, err := cache.NewMemory(ctx, cache.MemoryConfig{MaxTTL: 100 * time.Millisecond})
assert.NoError(t, err)
return c
})
Expand Down
2 changes: 2 additions & 0 deletions internal/cache/remote/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ func NewClient(baseURL string) *Client {
}
}

func (c *Client) String() string { return "remote:" + c.baseURL }

// Open retrieves an object from the remote cache.
func (c *Client) Open(ctx context.Context, key cache.Key) (io.ReadCloser, error) {
url := fmt.Sprintf("%s/%s", c.baseURL, key.String())
Expand Down
6 changes: 4 additions & 2 deletions internal/cache/remote/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,21 @@ import (
"github.com/block/sfptc/internal/cache/cachetest"
"github.com/block/sfptc/internal/cache/remote"
"github.com/block/sfptc/internal/logging"
"github.com/block/sfptc/internal/strategy"
)

func TestRemoteClient(t *testing.T) {
cachetest.Suite(t, func(t *testing.T) cache.Cache {
ctx := t.Context()
_, ctx = logging.Configure(ctx, logging.Config{Level: slog.LevelError})
memCache, err := cache.NewMemoryCache(ctx, cache.MemoryCacheConfig{
memCache, err := cache.NewMemory(ctx, cache.MemoryConfig{
MaxTTL: 100 * time.Millisecond,
})
assert.NoError(t, err)
t.Cleanup(func() { memCache.Close() })

server := remote.NewServer(ctx, memCache)
server, err := strategy.NewDefault(ctx, strategy.DefaultConfig{}, memCache)
assert.NoError(t, err)
ts := httptest.NewServer(server)
t.Cleanup(ts.Close)

Expand Down
74 changes: 74 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Package config loads HCL configuration and uses that to construct the cache backend, and proxy strategies.
package config

import (
"context"
"io"
"net/http"
"strings"

"github.com/alecthomas/errors"
"github.com/alecthomas/hcl/v2"

"github.com/block/sfptc/internal/cache"
"github.com/block/sfptc/internal/logging"
"github.com/block/sfptc/internal/strategy"
)

// Load HCL configuration and uses that to construct the cache backend, and proxy strategies.
func Load(ctx context.Context, r io.Reader, mux *http.ServeMux) error {
logger := logging.FromContext(ctx)
ast, err := hcl.Parse(r)
if err != nil {
return errors.WithStack(err)
}

strategyCandidates := []*hcl.Block{
// Always enable the default strategy
{Name: "default", Labels: []string{"/api/v1/"}},
}

// First pass, instantiate caches
var caches []cache.Cache
for _, node := range ast.Entries {
switch node := node.(type) {
case *hcl.Block:
c, err := cache.Create(ctx, node.Name, node)
if errors.Is(err, cache.ErrNotFound) {
strategyCandidates = append(strategyCandidates, node)
continue
} else if err != nil {
return errors.Errorf("%s: %w", node.Pos, err)
}
caches = append(caches, c)

case *hcl.Attribute:
return errors.Errorf("%s: attributes are not allowed", node.Pos)
}
}
if len(caches) != 1 {
return errors.Errorf("%s: expected exactly one cache backend, got %d", ast.Pos, len(caches))
}

cache := caches[0]

logger.DebugContext(ctx, "Cache backend", "cache", cache)

// Second pass, instantiate strategies and bind them to the mux.
for _, block := range strategyCandidates {
if len(block.Labels) != 1 {
return errors.Errorf("%s: block must have exactly one label defining the server mount point", block.Pos)
}
pattern := block.Labels[0]
block.Labels = nil
s, err := strategy.Create(ctx, block.Name, block, cache)
if err != nil {
return errors.Errorf("%s: %w", block.Pos, err)
}

logger.DebugContext(ctx, "Adding strategy", "strategy", s, "pattern", pattern)

mux.Handle(pattern, http.StripPrefix(strings.TrimSuffix(pattern, "/"), s))
}
return nil
}
Loading