diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 000000000..d64c3fd83
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,3 @@
+*
+!caddy-r2alias
+caddy-r2alias/**/.*
diff --git a/.github/workflows/docker--caddy-s3.yml b/.github/workflows/docker--caddy-s3.yml
index 6317dc5a8..06930258d 100644
--- a/.github/workflows/docker--caddy-s3.yml
+++ b/.github/workflows/docker--caddy-s3.yml
@@ -19,11 +19,16 @@ jobs:
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version: "1.26"
- cache-dependency-path: docker/images/caddy-s3/modules/r2alias/go.sum
+ cache-dependency-path: caddy-r2alias/go.sum
- name: Run module tests
- working-directory: docker/images/caddy-s3/modules/r2alias
- run: go test -race -count=1 ./...
+ working-directory: caddy-r2alias
+ shell: bash
+ run: |
+ go test -race -count=1 -v ./... 2>&1 | tee /tmp/module-tests.log
+ # caddytest skips the end-to-end tests when a prerequisite fails, and
+ # an all-skipped run exits 0 — assert nothing was skipped.
+ ! grep -q -- "--- SKIP" /tmp/module-tests.log
build-and-push:
name: Build + push image
@@ -67,7 +72,7 @@ jobs:
- name: Build and push Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with:
- context: ./docker/images/caddy-s3
+ context: .
file: ./docker/images/caddy-s3/Dockerfile
platforms: linux/amd64
push: true
diff --git a/.gitignore b/.gitignore
index 73e48f5ac..585aa2dba 100644
--- a/.gitignore
+++ b/.gitignore
@@ -74,3 +74,6 @@ loadtest/results/
# dossier: internal scratchpad theater tooling
.scratchpad/
+
+# per-directory agent state (also covered by a personal global ignore)
+.claude/
diff --git a/caddy-r2alias/cache.go b/caddy-r2alias/cache.go
new file mode 100644
index 000000000..5f6d37b47
--- /dev/null
+++ b/caddy-r2alias/cache.go
@@ -0,0 +1,99 @@
+package r2alias
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/hashicorp/golang-lru/v2/expirable"
+ "golang.org/x/sync/singleflight"
+)
+
+// aliasCache is a bounded LRU with per-entry TTL and singleflight stampede
+// control. It holds both hit and missing-alias sentinel entries so scan
+// traffic against dead sites is absorbed by the cache rather than amplified
+// to R2.
+type aliasCache struct {
+ lru *expirable.LRU[string, aliasEntry]
+ sf singleflight.Group
+ fetchTimeout time.Duration
+}
+
+// newAliasCache clamps a non-positive bound to its default. expirable.NewLRU
+// reads size 0 as unbounded and ttl 0 as never-expiring, and the cache key
+// carries the Host header, so an unclamped zero is a memory sink and a stuck
+// alias flip.
+func newAliasCache(size int, ttl, fetchTimeout time.Duration) *aliasCache {
+ if size <= 0 {
+ size = defaultCacheMaxEntries
+ }
+ if ttl <= 0 {
+ ttl = defaultCacheTTL
+ }
+ if fetchTimeout <= 0 {
+ fetchTimeout = defaultFetchTimeout
+ }
+ return &aliasCache{
+ lru: expirable.NewLRU[string, aliasEntry](size, nil, ttl),
+ fetchTimeout: fetchTimeout,
+ }
+}
+
+func cacheKey(bucket, site, aliasName string) string {
+ return bucket + "/" + site + "/" + aliasName
+}
+
+// Resolve returns the cached entry or invokes fetchFn (coalesced via
+// singleflight). Errors are never cached — sticky errors would amplify
+// upstream outages across the TTL window.
+//
+// The shared flight runs on a context detached from every caller and bounded
+// by fetchTimeout, so whichever caller happened to start it cannot cancel the
+// result the others are waiting on. Each caller still honours its own context.
+func (c *aliasCache) Resolve(
+ ctx context.Context,
+ bucket, site, aliasName string,
+ fetchFn func(context.Context, string) (aliasEntry, error),
+) (aliasEntry, error) {
+ key := cacheKey(bucket, site, aliasName)
+
+ if entry, ok := c.lru.Get(key); ok {
+ return entry, nil
+ }
+
+ ch := c.sf.DoChan(key, func() (val any, err error) {
+ // A DoChan flight re-panics on a detached goroutine that no caller
+ // frame can recover, so ServeHTTP's own recover cannot see it and the
+ // process dies. Convert it here to keep that guarantee.
+ defer func() {
+ if rec := recover(); rec != nil {
+ err = fmt.Errorf("r2_alias: alias fetch panic: %v", rec)
+ }
+ }()
+
+ // Re-check inside the flight: a concurrent winner may have populated
+ // the cache between our miss and acquiring the singleflight slot.
+ if entry, ok := c.lru.Get(key); ok {
+ return entry, nil
+ }
+ fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), c.fetchTimeout)
+ defer cancel()
+
+ entry, ferr := fetchFn(fetchCtx, key)
+ if ferr != nil {
+ return aliasEntry{}, ferr
+ }
+ c.lru.Add(key, entry)
+ return entry, nil
+ })
+
+ select {
+ case res := <-ch:
+ if res.Err != nil {
+ return aliasEntry{}, res.Err
+ }
+ return res.Val.(aliasEntry), nil
+ case <-ctx.Done():
+ return aliasEntry{}, ctx.Err()
+ }
+}
diff --git a/docker/images/caddy-s3/modules/r2alias/cache_test.go b/caddy-r2alias/cache_test.go
similarity index 73%
rename from docker/images/caddy-s3/modules/r2alias/cache_test.go
rename to caddy-r2alias/cache_test.go
index c11a38ebe..a9c7bf3ec 100644
--- a/docker/images/caddy-s3/modules/r2alias/cache_test.go
+++ b/caddy-r2alias/cache_test.go
@@ -11,7 +11,7 @@ import (
)
func TestCache_HitAfterMiss(t *testing.T) {
- c := newAliasCache(10, 1*time.Second)
+ c := newAliasCache(10, 1*time.Second, time.Minute)
var calls int32
fetch := func(_ context.Context, _ string) (aliasEntry, error) {
atomic.AddInt32(&calls, 1)
@@ -39,7 +39,7 @@ func TestCache_HitAfterMiss(t *testing.T) {
}
func TestCache_TTLExpiry(t *testing.T) {
- c := newAliasCache(10, 50*time.Millisecond)
+ c := newAliasCache(10, 50*time.Millisecond, time.Minute)
var calls int32
fetch := func(_ context.Context, _ string) (aliasEntry, error) {
atomic.AddInt32(&calls, 1)
@@ -59,7 +59,7 @@ func TestCache_TTLExpiry(t *testing.T) {
}
func TestCache_LRUEvictionAtCapacity(t *testing.T) {
- c := newAliasCache(3, 10*time.Second)
+ c := newAliasCache(3, 10*time.Second, time.Minute)
var mu sync.Mutex
fetchLog := []string{}
@@ -99,7 +99,7 @@ func TestCache_LRUEvictionAtCapacity(t *testing.T) {
}
func TestCache_MissingSentinelCached(t *testing.T) {
- c := newAliasCache(10, 1*time.Second)
+ c := newAliasCache(10, 1*time.Second, time.Minute)
var calls int32
fetch := func(_ context.Context, _ string) (aliasEntry, error) {
atomic.AddInt32(&calls, 1)
@@ -128,7 +128,7 @@ func TestCache_MissingSentinelCached(t *testing.T) {
func TestCache_Singleflight(t *testing.T) {
const concurrency = 1000
- c := newAliasCache(10, 1*time.Second)
+ c := newAliasCache(10, 1*time.Second, time.Minute)
var calls int32
fetch := func(_ context.Context, _ string) (aliasEntry, error) {
@@ -157,7 +157,7 @@ func TestCache_Singleflight(t *testing.T) {
// Sticky errors would amplify outages, so fetchFn errors are never cached.
func TestCache_ErrorNotCached(t *testing.T) {
- c := newAliasCache(10, 1*time.Second)
+ c := newAliasCache(10, 1*time.Second, time.Minute)
var calls int32
testErr := errors.New("transient r2 failure")
@@ -185,7 +185,7 @@ func TestCache_ErrorNotCached(t *testing.T) {
}
func TestCache_KeyComposition(t *testing.T) {
- c := newAliasCache(10, 1*time.Second)
+ c := newAliasCache(10, 1*time.Second, time.Minute)
var mu sync.Mutex
seen := map[string]struct{}{}
@@ -219,3 +219,76 @@ func TestCache_KeyComposition(t *testing.T) {
}
}
}
+
+func TestNewAliasCache_ClampsNonPositiveBounds(t *testing.T) {
+ c := newAliasCache(0, 0, time.Minute)
+
+ for i := 0; i < defaultCacheMaxEntries+1; i++ {
+ c.lru.Add(fmt.Sprintf("k%d", i), aliasEntry{DeployID: "d", Present: true})
+ }
+
+ if got := c.lru.Len(); got > defaultCacheMaxEntries {
+ t.Fatalf("a size-0 cache must clamp to %d entries, held %d", defaultCacheMaxEntries, got)
+ }
+}
+
+func TestCache_LeaderExitDoesNotFailWaiters(t *testing.T) {
+ c := newAliasCache(10, time.Minute, time.Minute)
+
+ release := make(chan struct{})
+ var started sync.WaitGroup
+ started.Add(1)
+ var once sync.Once
+ fetch := func(ctx context.Context, _ string) (aliasEntry, error) {
+ once.Do(started.Done)
+ select {
+ case <-release:
+ return aliasEntry{DeployID: "d1", Present: true}, nil
+ case <-ctx.Done():
+ return aliasEntry{}, ctx.Err()
+ }
+ }
+
+ leaderCtx, cancelLeader := context.WithCancel(context.Background())
+ leaderDone := make(chan error, 1)
+ go func() {
+ _, err := c.Resolve(leaderCtx, "b", "site-a", "production", fetch)
+ leaderDone <- err
+ }()
+
+ started.Wait()
+
+ const waiters = 5
+ results := make(chan error, waiters)
+ for i := 0; i < waiters; i++ {
+ go func() {
+ _, err := c.Resolve(context.Background(), "b", "site-a", "production", fetch)
+ results <- err
+ }()
+ }
+ time.Sleep(50 * time.Millisecond)
+
+ cancelLeader()
+ <-leaderDone
+ close(release)
+
+ for i := 0; i < waiters; i++ {
+ select {
+ case err := <-results:
+ if err != nil {
+ t.Fatalf("waiter %d: the leader leaving must not fail it: %v", i, err)
+ }
+ case <-time.After(3 * time.Second):
+ t.Fatalf("waiter %d: never returned", i)
+ }
+ }
+}
+
+func TestCache_FetchTimeoutBoundsTheFlight(t *testing.T) {
+ c := newAliasCache(10, time.Minute, 30*time.Millisecond)
+
+ _, err := c.Resolve(context.Background(), "b", "site-a", "production", blockingFetcher)
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("want context.DeadlineExceeded, got %v", err)
+ }
+}
diff --git a/docker/images/caddy-s3/modules/r2alias/caddyfile.go b/caddy-r2alias/caddyfile.go
similarity index 90%
rename from docker/images/caddy-s3/modules/r2alias/caddyfile.go
rename to caddy-r2alias/caddyfile.go
index 71af2735b..4aa2ad110 100644
--- a/docker/images/caddy-s3/modules/r2alias/caddyfile.go
+++ b/caddy-r2alias/caddyfile.go
@@ -27,6 +27,7 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error)
// secret_access_key
// cache_ttl
// cache_max_entries
+// fetch_timeout
// preview_subdomain
// root_domain
// deploy_id_regex
@@ -74,6 +75,15 @@ func (r *R2Alias) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
return d.Errf("cache_ttl: %v", err)
}
r.CacheTTL = dur
+ case "fetch_timeout":
+ if !d.NextArg() {
+ return d.ArgErr()
+ }
+ dur, err := time.ParseDuration(d.Val())
+ if err != nil {
+ return d.Errf("fetch_timeout: %v", err)
+ }
+ r.FetchTimeout = dur
case "cache_max_entries":
if !d.NextArg() {
return d.ArgErr()
diff --git a/docker/images/caddy-s3/modules/r2alias/filesystem.go b/caddy-r2alias/filesystem.go
similarity index 63%
rename from docker/images/caddy-s3/modules/r2alias/filesystem.go
rename to caddy-r2alias/filesystem.go
index db19e92c3..64f3cffd7 100644
--- a/docker/images/caddy-s3/modules/r2alias/filesystem.go
+++ b/caddy-r2alias/filesystem.go
@@ -54,6 +54,8 @@ type R2FS struct {
// tests swap in a stub so Open/Stat run without an S3 client.
fetcher func(ctx context.Context, key string) (*r2Object, error)
+ header func(ctx context.Context, key string) (*r2Object, error)
+
// indexProbe reports whether a directory's index.html exists. Provision
// wires it to r.hasIndex, which issues a HeadObject — matching the Caddy
// key's GetObject-only IAM scope (RFC §4.2.4). Backs Open's virtual-
@@ -87,7 +89,7 @@ func (r *R2FS) Provision(ctx caddy.Context) error {
return fmt.Errorf("caddy.fs.r2: endpoint is required")
}
if r.Region == "" {
- r.Region = "auto"
+ r.Region = defaultRegion
}
if r.MaxFileSize <= 0 {
r.MaxFileSize = defaultMaxFileSize
@@ -111,6 +113,9 @@ func (r *R2FS) Provision(ctx caddy.Context) error {
if r.fetcher == nil {
r.fetcher = r.getObject
}
+ if r.header == nil {
+ r.header = r.headObject
+ }
if r.indexProbe == nil {
r.indexProbe = r.hasIndex
}
@@ -190,56 +195,118 @@ func (r *R2FS) Open(name string) (fs.File, error) {
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
- obj, err := r.fetcher(ctx, name)
- if err != nil {
- if !errors.Is(err, fs.ErrNotExist) {
- return nil, &fs.PathError{Op: "open", Path: name, Err: err}
+ head, err := r.header(ctx, name)
+ if err == nil {
+ if head.Size > r.MaxFileSize {
+ r.logger.Warn("r2 object exceeds max_file_size",
+ zap.String("path", name), zap.Int64("size", head.Size))
+ return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
}
- // Only promote extensionless misses to a virtual-directory probe;
- // paths with extensions are full object keys and a miss is terminal.
- if path.Ext(name) == "" && r.indexProbe != nil {
- has, probeErr := r.indexProbe(ctx, name)
- if probeErr != nil {
- r.logger.Warn("r2 index probe failed",
- zap.String("path", name), zap.Error(probeErr))
- return nil, &fs.PathError{Op: "open", Path: name, Err: probeErr}
- }
- if has {
- r.logger.Debug("r2 virtual directory", zap.String("path", name))
- return &r2File{
- reader: bytes.NewReader(nil),
- info: &r2FileInfo{
- name: path.Base(name),
- isDir: true,
- },
- }, nil
- }
+ return &r2File{
+ info: &r2FileInfo{
+ name: path.Base(name),
+ size: head.Size,
+ modTime: head.LastModified,
+ },
+ load: func() ([]byte, error) {
+ loadCtx, loadCancel := context.WithTimeout(context.Background(), opTimeout)
+ defer loadCancel()
+ obj, ferr := r.fetcher(loadCtx, name)
+ if ferr != nil {
+ r.logger.Error("r2 body fetch failed",
+ zap.String("path", name), zap.Error(ferr))
+ return nil, ferr
+ }
+ return obj.Body, nil
+ },
+ }, nil
+ }
+ if !errors.Is(err, fs.ErrNotExist) {
+ return nil, &fs.PathError{Op: "open", Path: name, Err: err}
+ }
+
+ if r.indexProbe != nil {
+ has, probeErr := r.indexProbe(ctx, name)
+ if probeErr != nil {
+ r.logger.Warn("r2 index probe failed",
+ zap.String("path", name), zap.Error(probeErr))
+ return nil, &fs.PathError{Op: "open", Path: name, Err: probeErr}
+ }
+ if has {
+ r.logger.Debug("r2 virtual directory", zap.String("path", name))
+ return &r2File{
+ info: &r2FileInfo{
+ name: path.Base(name),
+ isDir: true,
+ },
+ }, nil
}
- return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}
- return &r2File{
- reader: bytes.NewReader(obj.Body),
- info: &r2FileInfo{
+ return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
+}
+
+// Unreachable through file_server: caddy v2.11.3 registers every filesystem in
+// internal/filesystems.wrapperFs, which embeds only fs.FS and hides fs.StatFS.
+func (r *R2FS) Stat(name string) (fs.FileInfo, error) {
+ if !fs.ValidPath(name) {
+ return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrInvalid}
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
+ defer cancel()
+
+ obj, err := r.header(ctx, name)
+ if err == nil {
+ return &r2FileInfo{
name: path.Base(name),
size: obj.Size,
modTime: obj.LastModified,
- },
- }, nil
+ }, nil
+ }
+ if !errors.Is(err, fs.ErrNotExist) {
+ return nil, &fs.PathError{Op: "stat", Path: name, Err: err}
+ }
+
+ if r.indexProbe != nil {
+ has, probeErr := r.indexProbe(ctx, name)
+ if probeErr != nil {
+ r.logger.Warn("r2 index probe failed",
+ zap.String("path", name), zap.Error(probeErr))
+ return nil, &fs.PathError{Op: "stat", Path: name, Err: probeErr}
+ }
+ if has {
+ return &r2FileInfo{name: path.Base(name), isDir: true}, nil
+ }
+ }
+ return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrNotExist}
}
-// Stat delegates to Open so both paths share one fetcher. Static-serving
-// traffic opens the file anyway (http.ServeContent calls Stat then reads).
-func (r *R2FS) Stat(name string) (fs.FileInfo, error) {
- f, err := r.Open(name)
+func (r *R2FS) headObject(ctx context.Context, key string) (*r2Object, error) {
+ out, err := r.client.HeadObject(ctx, &s3.HeadObjectInput{
+ Bucket: aws.String(r.Bucket),
+ Key: aws.String(key),
+ })
if err != nil {
- var pe *fs.PathError
- if errors.As(err, &pe) {
- pe.Op = "stat"
+ if isNoSuchKey(err) {
+ return nil, fmt.Errorf("caddy.fs.r2: %w", fs.ErrNotExist)
}
- return nil, err
+ var respErr *awshttp.ResponseError
+ if errors.As(err, &respErr) && respErr.HTTPStatusCode() >= 500 {
+ return nil, fmt.Errorf("caddy.fs.r2: upstream 5xx: %w", err)
+ }
+ return nil, fmt.Errorf("caddy.fs.r2: HeadObject %s: %w", key, err)
}
- defer func() { _ = f.Close() }()
- return f.Stat()
+
+ obj := &r2Object{}
+ if out.ContentLength != nil {
+ obj.Size = *out.ContentLength
+ }
+ if out.LastModified != nil {
+ obj.LastModified = *out.LastModified
+ }
+ if out.ContentType != nil {
+ obj.ContentType = *out.ContentType
+ }
+ return obj, nil
}
func (r *R2FS) getObject(ctx context.Context, key string) (*r2Object, error) {
@@ -290,16 +357,12 @@ func (r *R2FS) getObject(ctx context.Context, key string) (*r2Object, error) {
// — matching the scope granted to the Caddy read-only key (RFC §4.2.4) —
// where ListObjectsV2 would need s3:ListBucket, which is not granted.
func (r *R2FS) hasIndex(ctx context.Context, dirPath string) (bool, error) {
- key := dirPath + "/" + indexFile
- _, err := r.client.HeadObject(ctx, &s3.HeadObjectInput{
- Bucket: aws.String(r.Bucket),
- Key: aws.String(key),
- })
+ _, err := r.headObject(ctx, dirPath+"/"+indexFile)
+ if errors.Is(err, fs.ErrNotExist) {
+ return false, nil
+ }
if err != nil {
- if isNoSuchKey(err) {
- return false, nil
- }
- return false, fmt.Errorf("caddy.fs.r2: HeadObject %s: %w", key, err)
+ return false, err
}
return true, nil
}
@@ -319,15 +382,83 @@ func isNoSuchKey(err error) bool {
}
type r2File struct {
- reader *bytes.Reader
info *r2FileInfo
+ load func() ([]byte, error)
+ reader *bytes.Reader
+ offset int64
+}
+
+func (f *r2File) body() (*bytes.Reader, error) {
+ if f.reader != nil {
+ return f.reader, nil
+ }
+ var raw []byte
+ if f.load != nil {
+ var err error
+ if raw, err = f.load(); err != nil {
+ return nil, err
+ }
+ }
+ // A HeadObject size that disagrees with the GetObject body means the object
+ // changed between the two calls. ServeContent has already sent the HEAD size
+ // as Content-Length, so fail the read rather than silently under-deliver.
+ if f.load != nil && int64(len(raw)) != f.info.size {
+ return nil, fmt.Errorf("caddy.fs.r2: body %d bytes, HeadObject declared %d",
+ len(raw), f.info.size)
+ }
+ f.reader = bytes.NewReader(raw)
+ if _, err := f.reader.Seek(f.offset, io.SeekStart); err != nil {
+ return nil, err
+ }
+ return f.reader, nil
+}
+
+func (f *r2File) Stat() (fs.FileInfo, error) { return f.info, nil }
+
+func (f *r2File) Read(p []byte) (int, error) {
+ reader, err := f.body()
+ if err != nil {
+ return 0, err
+ }
+ n, readErr := reader.Read(p)
+ f.offset += int64(n)
+ return n, readErr
+}
+
+func (f *r2File) Seek(offset int64, whence int) (int64, error) {
+ if f.reader != nil {
+ pos, err := f.reader.Seek(offset, whence)
+ f.offset = pos
+ return pos, err
+ }
+
+ var abs int64
+ switch whence {
+ case io.SeekStart:
+ abs = offset
+ case io.SeekCurrent:
+ abs = f.offset + offset
+ case io.SeekEnd:
+ abs = f.info.size + offset
+ default:
+ return 0, fs.ErrInvalid
+ }
+ if abs < 0 {
+ return 0, fs.ErrInvalid
+ }
+ f.offset = abs
+ return abs, nil
+}
+
+func (f *r2File) ReadAt(p []byte, off int64) (int, error) {
+ reader, err := f.body()
+ if err != nil {
+ return 0, err
+ }
+ return reader.ReadAt(p, off)
}
-func (f *r2File) Stat() (fs.FileInfo, error) { return f.info, nil }
-func (f *r2File) Read(b []byte) (int, error) { return f.reader.Read(b) }
-func (f *r2File) Seek(offset int64, whence int) (int64, error) { return f.reader.Seek(offset, whence) }
-func (f *r2File) ReadAt(p []byte, off int64) (int, error) { return f.reader.ReadAt(p, off) }
-func (f *r2File) Close() error { return nil }
+func (f *r2File) Close() error { return nil }
type r2FileInfo struct {
name string
diff --git a/docker/images/caddy-s3/modules/r2alias/filesystem_test.go b/caddy-r2alias/filesystem_test.go
similarity index 68%
rename from docker/images/caddy-s3/modules/r2alias/filesystem_test.go
rename to caddy-r2alias/filesystem_test.go
index bf7f23425..6d38666a3 100644
--- a/docker/images/caddy-s3/modules/r2alias/filesystem_test.go
+++ b/caddy-r2alias/filesystem_test.go
@@ -16,12 +16,17 @@ import (
// Tests assign r.fetcher to control how Open resolves S3 GetObject. No AWS
// SDK client is constructed.
func newTestR2FS() *R2FS {
- return &R2FS{
- Bucket: "test-bucket",
- Endpoint: "https://r2.example",
- Region: "auto",
- logger: zap.NewNop(),
+ r := &R2FS{
+ Bucket: "test-bucket",
+ Endpoint: "https://r2.example",
+ Region: "auto",
+ MaxFileSize: defaultMaxFileSize,
+ logger: zap.NewNop(),
}
+ r.header = func(ctx context.Context, key string) (*r2Object, error) {
+ return r.fetcher(ctx, key)
+ }
+ return r
}
func stubFSFetcher(obj *r2Object, err error) func(context.Context, string) (*r2Object, error) {
@@ -279,27 +284,91 @@ func TestR2FS_Open_NotFound_NoIndex(t *testing.T) {
}
}
-// Paths with a file extension are full object keys — probing for index.html
-// on scan traffic (e.g. `/wp-admin.php`) would amplify cost. Skip the probe.
-func TestR2FS_Open_NotFound_SkipsProbeForExtensionPath(t *testing.T) {
+func TestR2FS_Open_NotFound_ProbesDottedDirectory(t *testing.T) {
t.Parallel()
r := newTestR2FS()
r.fetcher = stubFSFetcher(nil, fs.ErrNotExist)
- r.indexProbe = func(context.Context, string) (bool, error) {
- t.Fatal("indexProbe should NOT run when path has an extension")
- return false, nil
+ probed := ""
+ r.indexProbe = func(_ context.Context, dir string) (bool, error) {
+ probed = dir
+ return true, nil
}
- _, err := r.Open("site-a.test.camp/deploys/v1/wp-admin.php")
- if !errors.Is(err, fs.ErrNotExist) {
- t.Errorf("error should wrap fs.ErrNotExist, got %v", err)
+ f, err := r.Open("site-a.test.camp/deploys/v1/assets.min")
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ defer func() { _ = f.Close() }()
+
+ info, err := f.Stat()
+ if err != nil {
+ t.Fatalf("Stat: %v", err)
+ }
+ if !info.IsDir() {
+ t.Error("a dotted directory must resolve to a virtual directory")
+ }
+ if probed != "site-a.test.camp/deploys/v1/assets.min" {
+ t.Errorf("probe path: got %q", probed)
}
}
-func TestR2FS_Stat_VirtualDirectory(t *testing.T) {
+func TestR2FS_Open_RejectsOversizeObject(t *testing.T) {
+ t.Parallel()
+ r := newTestR2FS()
+ r.MaxFileSize = 1024
+ r.header = stubFSFetcher(&r2Object{Size: 4096}, nil)
+ r.fetcher = func(context.Context, string) (*r2Object, error) {
+ t.Fatal("an oversize object must be rejected before its body is fetched")
+ return nil, nil
+ }
+
+ if _, err := r.Open("site-a.test.camp/deploys/v1/big.bin"); !errors.Is(err, fs.ErrInvalid) {
+ t.Fatalf("error should wrap fs.ErrInvalid, got %v", err)
+ }
+}
+
+func TestR2FS_Open_LoadFailureSurfacesOnRead(t *testing.T) {
t.Parallel()
r := newTestR2FS()
+ r.header = stubFSFetcher(&r2Object{Size: 10}, nil)
r.fetcher = stubFSFetcher(nil, fs.ErrNotExist)
+
+ f, err := r.Open("site-a.test.camp/deploys/v1/vanished.html")
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ defer func() { _ = f.Close() }()
+
+ if _, readErr := f.Read(make([]byte, 4)); !errors.Is(readErr, fs.ErrNotExist) {
+ t.Fatalf("read after the object vanished: want fs.ErrNotExist, got %v", readErr)
+ }
+}
+
+func TestR2FS_Stat_UsesHeadNotGet(t *testing.T) {
+ t.Parallel()
+ r := newTestR2FS()
+ r.fetcher = func(context.Context, string) (*r2Object, error) {
+ t.Fatal("Stat must not download a body")
+ return nil, nil
+ }
+ r.header = stubFSFetcher(&r2Object{Size: 12}, nil)
+
+ info, err := r.Stat("site-a.test.camp/deploys/v1/index.html")
+ if err != nil {
+ t.Fatalf("Stat: %v", err)
+ }
+ if info.Size() != 12 {
+ t.Errorf("size: want 12, got %d", info.Size())
+ }
+ if info.IsDir() {
+ t.Error("an object Stat must not report IsDir")
+ }
+}
+
+func TestR2FS_Stat_VirtualDirectory(t *testing.T) {
+ t.Parallel()
+ r := newTestR2FS()
+ r.header = stubFSFetcher(nil, fs.ErrNotExist)
r.indexProbe = stubIndexProbe(true, nil)
info, err := r.Stat("site-a.test.camp/deploys/v1")
@@ -310,3 +379,46 @@ func TestR2FS_Stat_VirtualDirectory(t *testing.T) {
t.Error("virtual directory Stat should report IsDir=true")
}
}
+
+func TestR2FS_Open_SeekEndDoesNotFetchTheBody(t *testing.T) {
+ r := newTestR2FS()
+ r.header = stubFSFetcher(&r2Object{Size: 5000}, nil)
+ fetched := false
+ r.fetcher = func(context.Context, string) (*r2Object, error) {
+ fetched = true
+ return &r2Object{Body: []byte("SHORT-BODY")}, nil
+ }
+
+ f, err := r.Open("site-a.test.camp/deploys/v1/index.html")
+ if err != nil {
+ t.Fatalf("Open: unexpected error: %v", err)
+ }
+ defer func() { _ = f.Close() }()
+
+ size, err := f.(io.Seeker).Seek(0, io.SeekEnd)
+ if err != nil {
+ t.Fatalf("Seek: unexpected error: %v", err)
+ }
+ if size != 5000 {
+ t.Errorf("Seek(0, SeekEnd): want the HeadObject size 5000, got %d", size)
+ }
+ if fetched {
+ t.Error("sizing the response must not fetch the body")
+ }
+}
+
+func TestR2FS_Open_SkewedBodyFailsTheRead(t *testing.T) {
+ r := newTestR2FS()
+ r.header = stubFSFetcher(&r2Object{Size: 5000}, nil)
+ r.fetcher = stubFSFetcher(&r2Object{Body: []byte("SHORT-BODY")}, nil)
+
+ f, err := r.Open("site-a.test.camp/deploys/v1/index.html")
+ if err != nil {
+ t.Fatalf("Open: unexpected error: %v", err)
+ }
+ defer func() { _ = f.Close() }()
+
+ if _, err := io.ReadAll(f); err == nil {
+ t.Fatal("a body shorter than the declared size must fail, not short-serve")
+ }
+}
diff --git a/docker/images/caddy-s3/modules/r2alias/go.mod b/caddy-r2alias/go.mod
similarity index 83%
rename from docker/images/caddy-s3/modules/r2alias/go.mod
rename to caddy-r2alias/go.mod
index c8e8c5fc9..1ed30dc92 100644
--- a/docker/images/caddy-s3/modules/r2alias/go.mod
+++ b/caddy-r2alias/go.mod
@@ -1,4 +1,4 @@
-module github.com/freeCodeCamp-Universe/infra/docker/images/caddy-s3/modules/r2alias
+module github.com/freeCodeCamp-Universe/infra/caddy-r2alias
go 1.25.0
@@ -9,7 +9,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1
github.com/caddyserver/caddy/v2 v2.11.3
github.com/hashicorp/golang-lru/v2 v2.0.7
- github.com/testcontainers/testcontainers-go v0.42.0
go.uber.org/zap v1.27.1
golang.org/x/sync v0.20.0
)
@@ -23,14 +22,12 @@ require (
filippo.io/bigmod v0.1.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
- github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/DeRuina/timberjack v1.4.2 // indirect
github.com/KimMachineGun/automemlimit v0.7.5 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
- github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect
@@ -52,30 +49,19 @@ require (
github.com/caddyserver/certmagic v0.25.3 // indirect
github.com/caddyserver/zerossl v0.1.5 // indirect
github.com/ccoveille/go-safecast/v2 v2.0.0 // indirect
- github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chzyer/readline v1.5.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
- github.com/containerd/errdefs v1.0.0 // indirect
- github.com/containerd/errdefs/pkg v0.3.0 // indirect
- github.com/containerd/log v0.1.0 // indirect
- github.com/containerd/platforms v0.2.1 // indirect
github.com/coreos/go-oidc/v3 v3.17.0 // indirect
- github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
- github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgraph-io/badger v1.6.2 // indirect
github.com/dgraph-io/badger/v2 v2.2007.4 // indirect
github.com/dgraph-io/ristretto v0.2.0 // indirect
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
- github.com/distribution/reference v0.6.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
- github.com/docker/go-connections v0.6.0 // indirect
- github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
- github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
@@ -83,7 +69,6 @@ require (
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
- github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
@@ -105,8 +90,6 @@ require (
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/libdns/libdns v1.1.1 // indirect
- github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
- github.com/magiconair/properties v1.8.10 // indirect
github.com/manifoldco/promptui v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -116,23 +99,10 @@ require (
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
- github.com/moby/docker-image-spec v1.3.1 // indirect
- github.com/moby/go-archive v0.2.0 // indirect
- github.com/moby/moby/api v1.54.1 // indirect
- github.com/moby/moby/client v0.4.0 // indirect
- github.com/moby/patternmatcher v0.6.1 // indirect
- github.com/moby/sys/sequential v0.6.0 // indirect
- github.com/moby/sys/user v0.4.0 // indirect
- github.com/moby/sys/userns v0.1.0 // indirect
- github.com/moby/term v0.5.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
- github.com/opencontainers/go-digest v1.0.0 // indirect
- github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect
github.com/pires/go-proxyproto v0.11.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
- github.com/pmezard/go-difflib v1.0.0 // indirect
- github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
@@ -142,7 +112,6 @@ require (
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
- github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
@@ -158,16 +127,12 @@ require (
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
- github.com/stretchr/testify v1.11.1 // indirect
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
github.com/tailscale/tscert v0.0.0-20251216020129-aea342f6d747 // indirect
- github.com/tklauser/go-sysconf v0.3.16 // indirect
- github.com/tklauser/numcpus v0.11.0 // indirect
github.com/urfave/cli v1.22.17 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc // indirect
- github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
diff --git a/docker/images/caddy-s3/modules/r2alias/go.sum b/caddy-r2alias/go.sum
similarity index 88%
rename from docker/images/caddy-s3/modules/r2alias/go.sum
rename to caddy-r2alias/go.sum
index e9fb00638..d0575f9bf 100644
--- a/docker/images/caddy-s3/modules/r2alias/go.sum
+++ b/caddy-r2alias/go.sum
@@ -22,12 +22,8 @@ filippo.io/bigmod v0.1.0 h1:UNzDk7y9ADKST+axd9skUpBQeW7fG2KrTZyOE4uGQy8=
filippo.io/bigmod v0.1.0/go.mod h1:OjOXDNlClLblvXdwgFFOQFJEocLhhtai8vGLy0JCZlI=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
-github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
-github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 h1:cTp8I5+VIoKjsnZuH8vjyaysT/ses3EvZeaV/1UkF2M=
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
-github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
-github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
@@ -42,8 +38,6 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
-github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
-github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
@@ -107,8 +101,6 @@ github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7l
github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
github.com/ccoveille/go-safecast/v2 v2.0.0 h1:+5eyITXAUj3wMjad6cRVJKGnC7vDS55zk0INzJagub0=
github.com/ccoveille/go-safecast/v2 v2.0.0/go.mod h1:JIYA4CAR33blIDuE6fSwCp2sz1oOBahXnvmdBhOAABs=
-github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
-github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
@@ -126,27 +118,15 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
-github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
-github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
-github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
-github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
-github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
-github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
-github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
-github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
-github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
-github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
-github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
-github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -161,21 +141,13 @@ github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
-github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
-github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
-github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
-github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
-github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
-github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
-github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
-github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
@@ -196,8 +168,6 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
-github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
-github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -213,7 +183,6 @@ github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD
github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg=
github.com/google/certificate-transparency-go v1.1.8-0.20240110162603-74a5dd331745 h1:heyoXNxkRT155x4jTAiSv5BVSVkueifPUm+Q8LUXMRo=
github.com/google/certificate-transparency-go v1.1.8-0.20240110162603-74a5dd331745/go.mod h1:zN0wUQgV9LjwLZeFHnrAbQi8hzMVvEWePyk+MhPOk7k=
-github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -274,11 +243,7 @@ github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYq
github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ=
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
-github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
-github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
-github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
-github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
@@ -299,30 +264,8 @@ github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
-github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
-github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
-github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
-github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
-github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
-github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
-github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
-github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
-github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
-github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
-github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
-github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
-github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
-github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
-github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
-github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
-github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
-github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
-github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
-github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
-github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
-github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
@@ -335,8 +278,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
-github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
@@ -362,8 +303,6 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/schollz/jsonstore v1.1.0 h1:WZBDjgezFS34CHI+myb4s8GGpir3UMpy7vWoCeO0n6E=
github.com/schollz/jsonstore v1.1.0/go.mod h1:15c6+9guw8vDRyozGjN3FoILt0wpruJk9Pi66vjaZfg=
-github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
-github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
@@ -410,8 +349,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
-github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
-github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -426,12 +363,6 @@ github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
github.com/tailscale/tscert v0.0.0-20251216020129-aea342f6d747 h1:RnBbFMmodYzhC6adOjTbtUQXyzV8dcvKYbolzs6Qch0=
github.com/tailscale/tscert v0.0.0-20251216020129-aea342f6d747/go.mod h1:ejPAJui3kVK4u5TgMtqtXlWf5HnKh9fLy5kvpaeuas0=
-github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
-github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
-github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
-github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
-github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
-github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ=
github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo=
@@ -444,8 +375,6 @@ github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
-github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
-github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
@@ -577,11 +506,8 @@ golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -627,7 +553,6 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.271.0 h1:cIPN4qcUc61jlh7oXu6pwOQqbJW2GqYh5PS6rB2C/JY=
@@ -654,9 +579,5 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
-gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
-pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
-pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
diff --git a/docker/images/caddy-s3/modules/r2alias/host.go b/caddy-r2alias/host.go
similarity index 88%
rename from docker/images/caddy-s3/modules/r2alias/host.go
rename to caddy-r2alias/host.go
index 36e1c19c4..f795662be 100644
--- a/docker/images/caddy-s3/modules/r2alias/host.go
+++ b/caddy-r2alias/host.go
@@ -15,6 +15,11 @@ import (
// — preview and production share the same `{site}/deploys/{id}/*` prefix
// in R2; only the alias file differs.
func parseSiteAndAlias(host, rootDomain, previewSubdomain string) (site, alias string, err error) {
+ // site is spliced into the storage path; a separator would add segments.
+ if strings.ContainsAny(host, `/\`) {
+ return "", "", fmt.Errorf("host %q contains a path separator", host)
+ }
+
suffix := "." + rootDomain
if !strings.HasSuffix(host, suffix) {
return "", "", fmt.Errorf("host %q is not under root domain %q", host, rootDomain)
diff --git a/docker/images/caddy-s3/modules/r2alias/host_test.go b/caddy-r2alias/host_test.go
similarity index 94%
rename from docker/images/caddy-s3/modules/r2alias/host_test.go
rename to caddy-r2alias/host_test.go
index 9bd4e6861..7d7e71888 100644
--- a/docker/images/caddy-s3/modules/r2alias/host_test.go
+++ b/caddy-r2alias/host_test.go
@@ -63,6 +63,16 @@ func TestParseSiteAndAlias_TableDriven(t *testing.T) {
host: "preview.freecode.camp",
wantErr: true,
},
+ {
+ name: "slash in host rejected",
+ host: "a/../b.freecode.camp",
+ wantErr: true,
+ },
+ {
+ name: "backslash in host rejected",
+ host: `a\..\b.freecode.camp`,
+ wantErr: true,
+ },
}
for _, c := range cases {
diff --git a/caddy-r2alias/integration_test.go b/caddy-r2alias/integration_test.go
new file mode 100644
index 000000000..e770d7099
--- /dev/null
+++ b/caddy-r2alias/integration_test.go
@@ -0,0 +1,401 @@
+package r2alias_test
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/caddyserver/caddy/v2/caddytest"
+
+ _ "github.com/freeCodeCamp-Universe/infra/caddy-r2alias"
+)
+
+const testBucket = "gxy-cassiopeia-test"
+
+// rootDomain is test-only so production config is never a live target here.
+const rootDomain = "test.camp"
+
+// cacheTTL is short enough that TestIntegration_AliasFlip can wait past it
+// without slowing the suite.
+const cacheTTL = 500 * time.Millisecond
+
+// caddyAdminPort / caddyHTTPPort keep the in-process Caddy off the real
+// Caddy defaults so a developer running Caddy locally doesn't collide.
+const (
+ caddyAdminPort = 2999
+ caddyHTTPPort = 9080
+ caddyHTTPSPort = 9443
+)
+
+// The disk layout is independent of the S3 prefix so one fixture set can back
+// multiple site names.
+func uploadDeployFixtures(t *testing.T, stub *s3Stub, site, version string) {
+ t.Helper()
+
+ srcDir := filepath.Join("testdata", "site-a", "deploys", version)
+ err := filepath.Walk(srcDir, func(path string, info os.FileInfo, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if info.IsDir() {
+ return nil
+ }
+ rel := strings.TrimPrefix(filepath.ToSlash(path), filepath.ToSlash(srcDir)+"/")
+
+ body, readErr := os.ReadFile(path)
+ if readErr != nil {
+ return readErr
+ }
+ stub.put(fmt.Sprintf("%s/deploys/%s/%s", site, version, rel), string(body), "text/html")
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("upload fixtures %s/%s: %v", site, version, err)
+ }
+}
+
+func startCaddy(t *testing.T, s3Endpoint string) *caddytest.Tester {
+ t.Helper()
+ caddyfile := fmt.Sprintf(`
+{
+ admin localhost:%d
+ http_port %d
+ https_port %d
+ auto_https off
+ grace_period 1ns
+
+ order r2_alias before file_server
+
+ filesystem r2 r2 {
+ bucket %s
+ endpoint %s
+ region us-east-1
+ access_key_id test
+ secret_access_key test
+ use_path_style
+ }
+}
+
+:%d {
+ r2_alias {
+ bucket %s
+ endpoint %s
+ region us-east-1
+ access_key_id test
+ secret_access_key test
+ cache_ttl %s
+ fetch_timeout 2s
+ root_domain %s
+ }
+ file_server {
+ fs r2
+ }
+}
+`,
+ caddyAdminPort, caddyHTTPPort, caddyHTTPSPort,
+ testBucket, s3Endpoint,
+ caddyHTTPPort,
+ testBucket, s3Endpoint,
+ cacheTTL, rootDomain,
+ )
+ tester := caddytest.NewTester(t)
+ tester.InitServer(caddyfile, "caddyfile")
+ return tester
+}
+
+// doGet issues an HTTP GET with a virtual Host header and returns status + body.
+// The TCP target is always the caddytest HTTP listener on localhost.
+func doGet(t *testing.T, tester *caddytest.Tester, host, path string) (int, string) {
+ t.Helper()
+ resp, body := doGetResponse(t, tester, host, path)
+ return resp.StatusCode, body
+}
+
+// doGetResponse is doGet for a test that asserts on transport details. The body
+// is already drained and closed; read it from the returned string, not resp.Body.
+func doGetResponse(t *testing.T, tester *caddytest.Tester, host, path string) (*http.Response, string) {
+ t.Helper()
+ url := fmt.Sprintf("http://localhost:%d%s", caddyHTTPPort, path)
+ req, err := http.NewRequest(http.MethodGet, url, nil)
+ if err != nil {
+ t.Fatalf("new request: %v", err)
+ }
+ req.Host = host
+
+ resp, err := tester.Client.Do(req)
+ if err != nil {
+ t.Fatalf("GET %s (Host=%s): %v", url, host, err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatalf("read body: %v", err)
+ }
+ return resp, string(body)
+}
+
+// assertBodyContains checks substring inclusion so tests survive formatter
+// reflows of the HTML fixtures.
+func assertBodyContains(t *testing.T, body, want string) {
+ t.Helper()
+ if !strings.Contains(body, want) {
+ t.Fatalf("body mismatch: want substring %q, got %q", want, body)
+ }
+}
+
+func TestIntegration_ResolveProduction(t *testing.T) {
+ stub := startS3Stub(t)
+ site := "site-a." + rootDomain
+
+ uploadDeployFixtures(t, stub, site, "v1")
+ stub.putAlias(site, "production", "v1")
+
+ tester := startCaddy(t, stub.endpoint())
+
+ status, body := doGet(t, tester, site, "/")
+ if status != http.StatusOK {
+ t.Fatalf("status: want 200, got %d (body=%q)", status, body)
+ }
+ assertBodyContains(t, body, "V1")
+}
+
+func TestIntegration_AliasFlip(t *testing.T) {
+ stub := startS3Stub(t)
+ site := "site-a." + rootDomain
+
+ uploadDeployFixtures(t, stub, site, "v1")
+ uploadDeployFixtures(t, stub, site, "v2")
+ stub.putAlias(site, "production", "v1")
+
+ tester := startCaddy(t, stub.endpoint())
+
+ status, body := doGet(t, tester, site, "/")
+ if status != http.StatusOK {
+ t.Fatalf("pre-flip status: want 200, got %d (body=%q)", status, body)
+ }
+ assertBodyContains(t, body, "V1")
+
+ stub.putAlias(site, "production", "v2")
+
+ // Poll past the cache TTL — CI timing jitter makes a single post-TTL
+ // sleep brittle. 5s is generous relative to the 500ms TTL.
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ status, body = doGet(t, tester, site, "/")
+ if status == http.StatusOK && strings.Contains(body, "V2") {
+ return
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ t.Fatalf("post-flip never served V2 within 5s: last status=%d body=%q", status, body)
+}
+
+func TestIntegration_PreviewRouting(t *testing.T) {
+ stub := startS3Stub(t)
+ prodSite := "site-a." + rootDomain
+ previewHost := "site-a.preview." + rootDomain
+
+ uploadDeployFixtures(t, stub, prodSite, "v2")
+ stub.putAlias(prodSite, "preview", "v2")
+
+ tester := startCaddy(t, stub.endpoint())
+
+ status, body := doGet(t, tester, previewHost, "/")
+ if status != http.StatusOK {
+ t.Fatalf("status: want 200, got %d (body=%q)", status, body)
+ }
+ assertBodyContains(t, body, "V2")
+}
+
+func TestIntegration_DottedDirectoryServesIndex(t *testing.T) {
+ stub := startS3Stub(t)
+ site := "site-a." + rootDomain
+
+ uploadDeployFixtures(t, stub, site, "v1")
+ stub.putAlias(site, "production", "v1")
+ stub.put(site+"/deploys/v1/assets.min/index.html", "MINIFIED
", "text/html")
+
+ tester := startCaddy(t, stub.endpoint())
+
+ status, body := doGet(t, tester, site, "/assets.min/")
+ if status != http.StatusOK {
+ t.Fatalf("status: want 200, got %d (body=%q)", status, body)
+ }
+ assertBodyContains(t, body, "MINIFIED")
+}
+
+func TestIntegration_DottedDeployIDServesRoot(t *testing.T) {
+ stub := startS3Stub(t)
+ site := "site-a." + rootDomain
+
+ stub.put(site+"/deploys/v1.2.3/index.html", "V1.2.3
", "text/html")
+ stub.putAlias(site, "production", "v1.2.3")
+
+ tester := startCaddy(t, stub.endpoint())
+
+ status, body := doGet(t, tester, site, "/")
+ if status != http.StatusOK {
+ t.Fatalf("status: want 200, got %d (body=%q)", status, body)
+ }
+ assertBodyContains(t, body, "V1.2.3")
+}
+
+func TestIntegration_ServesIndexWithOneBodyFetch(t *testing.T) {
+ stub := startS3Stub(t)
+ site := "site-a." + rootDomain
+
+ uploadDeployFixtures(t, stub, site, "v1")
+ stub.putAlias(site, "production", "v1")
+
+ tester := startCaddy(t, stub.endpoint())
+
+ status, body := doGet(t, tester, site, "/")
+ if status != http.StatusOK {
+ t.Fatalf("status: want 200, got %d (body=%q)", status, body)
+ }
+
+ indexKey := site + "/deploys/v1/index.html"
+ ops := stub.opsFor(indexKey)
+ gets := 0
+ for _, op := range ops {
+ if strings.HasPrefix(op, http.MethodGet+" ") {
+ gets++
+ }
+ }
+ if gets != 1 {
+ t.Fatalf("body fetches for %s: want 1, got %d (ops=%v, all=%v)", indexKey, gets, ops, stub.allOps())
+ }
+}
+
+func TestIntegration_BareNotFoundAliasIs404(t *testing.T) {
+ stub := startS3Stub(t)
+ stub.setFailure(http.StatusNotFound)
+
+ tester := startCaddy(t, stub.endpoint())
+
+ status, body := doGet(t, tester, "dead."+rootDomain, "/")
+ if status != http.StatusNotFound {
+ t.Fatalf("status: want 404, got %d (body=%q)", status, body)
+ }
+}
+
+func TestIntegration_UpstreamServerErrorIs503(t *testing.T) {
+ stub := startS3Stub(t)
+ stub.setFailure(http.StatusInternalServerError)
+
+ tester := startCaddy(t, stub.endpoint())
+
+ status, body := doGet(t, tester, "site-a."+rootDomain, "/")
+ if status != http.StatusServiceUnavailable {
+ t.Fatalf("status: want 503, got %d (body=%q)", status, body)
+ }
+}
+
+func TestIntegration_MissingSite404(t *testing.T) {
+ stub := startS3Stub(t)
+ tester := startCaddy(t, stub.endpoint())
+
+ status, _ := doGet(t, tester, "dead."+rootDomain, "/")
+ if status != http.StatusNotFound {
+ t.Fatalf("status: want 404, got %d", status)
+ }
+}
+
+// TestIntegration_HeadGetSizeSkewNeverLooksComplete covers an object changing
+// between the HeadObject and the GetObject. Sizing the response truthfully would
+// cost a body fetch on every HEAD, so the response is allowed to break — but it
+// must never look like a complete short body.
+//
+// The failure is a truncated 200, not an error status: caddy has already
+// committed the header when the mismatch surfaces, so status-code monitoring
+// sees a healthy 200 and only a body-length check catches it.
+//
+// Reachability: artemis refuses to delete or move a deploy under a live alias
+// (409 deploy_aliased, internal/handler/deploy_delete.go), and GC, reconcile,
+// restore and rollback are all likewise guarded or byte-identical. The one path
+// that can overwrite a served key is the deploy session's own repeatable upload
+// — the deploy JWT outlives finalize, so the uploader can PUT over a key that
+// finalize just aliased. That is a narrow race, and only an authorized session
+// can open it.
+func TestIntegration_HeadGetSizeSkewNeverLooksComplete(t *testing.T) {
+ stub := startS3Stub(t)
+ site := "site-a." + rootDomain
+
+ uploadDeployFixtures(t, stub, site, "v1")
+ stub.putAlias(site, "production", "v1")
+ stub.putSkewed(site+"/deploys/v1/skew.html", "SHORT-BODY", "text/html", 5000)
+
+ tester := startCaddy(t, stub.endpoint())
+
+ url := fmt.Sprintf("http://localhost:%d/skew.html", caddyHTTPPort)
+ req, err := http.NewRequest(http.MethodGet, url, nil)
+ if err != nil {
+ t.Fatalf("new request: %v", err)
+ }
+ req.Host = site
+
+ resp, err := tester.Client.Do(req)
+ if err != nil {
+ // Only a truncated body is an acceptable transport failure here. Any
+ // other error means the server never came up and the test proved nothing.
+ if !strings.Contains(err.Error(), "EOF") {
+ t.Fatalf("GET /skew.html failed for an unrelated reason: %v", err)
+ }
+ return
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ body, readErr := io.ReadAll(resp.Body)
+ if readErr != nil {
+ return
+ }
+ if int64(len(body)) != resp.ContentLength {
+ t.Fatalf("silent short body: declared %d, delivered %d (%q)",
+ resp.ContentLength, len(body), body)
+ }
+}
+
+// TestIntegration_HeadDoesNotFetchTheBody pins the cost of a HEAD. Sizing the
+// response from a lazily-loaded body would make every HEAD pull the whole
+// object from R2 and discard it, which turns a cheap request into an origin
+// fetch bounded only by max_file_size.
+func TestIntegration_HeadDoesNotFetchTheBody(t *testing.T) {
+ stub := startS3Stub(t)
+ site := "site-a." + rootDomain
+
+ uploadDeployFixtures(t, stub, site, "v1")
+ stub.putAlias(site, "production", "v1")
+
+ tester := startCaddy(t, stub.endpoint())
+ doGet(t, tester, site, "/index.html")
+
+ before := len(stub.allOps())
+ req, err := http.NewRequest(http.MethodHead,
+ fmt.Sprintf("http://localhost:%d/index.html", caddyHTTPPort), nil)
+ if err != nil {
+ t.Fatalf("new request: %v", err)
+ }
+ req.Host = site
+
+ resp, err := tester.Client.Do(req)
+ if err != nil {
+ t.Fatalf("HEAD /index.html: %v", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status: want 200, got %d", resp.StatusCode)
+ }
+
+ for _, op := range stub.allOps()[before:] {
+ if strings.HasPrefix(op, "GET ") {
+ t.Fatalf("a HEAD must not fetch the body; ops were %v", stub.allOps()[before:])
+ }
+ }
+}
diff --git a/docker/images/caddy-s3/modules/r2alias/r2alias.go b/caddy-r2alias/r2alias.go
similarity index 79%
rename from docker/images/caddy-s3/modules/r2alias/r2alias.go
rename to caddy-r2alias/r2alias.go
index 265a21a94..3989ac830 100644
--- a/docker/images/caddy-s3/modules/r2alias/r2alias.go
+++ b/caddy-r2alias/r2alias.go
@@ -19,7 +19,6 @@ import (
"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"
- s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
@@ -37,6 +36,16 @@ var errS3ServerError = errors.New("r2_alias: upstream 5xx")
// or malicious object.
const maxAliasBodyBytes = 1024
+const (
+ defaultCacheMaxEntries = 10000
+ defaultCacheTTL = 15 * time.Second
+ defaultFetchTimeout = 2 * time.Second
+ defaultRegion = "auto"
+ defaultPreviewSubdomain = "preview"
+ defaultRootDomain = "freecode.camp"
+ defaultDeployIDRegex = `^[A-Za-z0-9._-]{1,64}$`
+)
+
type R2Alias struct {
Bucket string `json:"bucket"`
Endpoint string `json:"endpoint"`
@@ -48,6 +57,9 @@ type R2Alias struct {
PreviewSubdomain string `json:"preview_subdomain,omitempty"`
RootDomain string `json:"root_domain,omitempty"`
DeployIDRegex string `json:"deploy_id_regex,omitempty"`
+ // FetchTimeout is read once, by Provision, when it builds the cache. Setting
+ // it on a live handler does nothing; rebuild the cache to change it.
+ FetchTimeout time.Duration `json:"fetch_timeout,omitempty"`
client *s3.Client
cache *aliasCache
@@ -78,7 +90,36 @@ func (R2Alias) CaddyModule() caddy.ModuleInfo {
}
}
+// applyDefaults fills every unset field. Provision calls it before building
+// the cache; caddy runs Provision before Validate (context.go:426, :441), so
+// defaults set only in Validate would reach the cache one step too late.
+func (r *R2Alias) applyDefaults() {
+ if r.Region == "" {
+ r.Region = defaultRegion
+ }
+ if r.CacheTTL == 0 {
+ r.CacheTTL = defaultCacheTTL
+ }
+ if r.CacheMaxEntries == 0 {
+ r.CacheMaxEntries = defaultCacheMaxEntries
+ }
+ if r.PreviewSubdomain == "" {
+ r.PreviewSubdomain = defaultPreviewSubdomain
+ }
+ if r.RootDomain == "" {
+ r.RootDomain = defaultRootDomain
+ }
+ if r.DeployIDRegex == "" {
+ r.DeployIDRegex = defaultDeployIDRegex
+ }
+ if r.FetchTimeout == 0 {
+ r.FetchTimeout = defaultFetchTimeout
+ }
+}
+
func (r *R2Alias) Provision(ctx caddy.Context) error {
+ r.applyDefaults()
+
awsCfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion(r.Region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
@@ -93,7 +134,7 @@ func (r *R2Alias) Provision(ctx caddy.Context) error {
o.UsePathStyle = true
})
- r.cache = newAliasCache(r.CacheMaxEntries, r.CacheTTL)
+ r.cache = newAliasCache(r.CacheMaxEntries, r.CacheTTL, r.FetchTimeout)
r.logger = ctx.Logger()
if r.fetcher == nil {
r.fetcher = r.fetchAlias
@@ -109,24 +150,7 @@ func (r *R2Alias) Validate() error {
return fmt.Errorf("r2_alias: endpoint is required")
}
- if r.Region == "" {
- r.Region = "auto"
- }
- if r.CacheTTL == 0 {
- r.CacheTTL = 15 * time.Second
- }
- if r.CacheMaxEntries == 0 {
- r.CacheMaxEntries = 10000
- }
- if r.PreviewSubdomain == "" {
- r.PreviewSubdomain = "preview"
- }
- if r.RootDomain == "" {
- r.RootDomain = "freecode.camp"
- }
- if r.DeployIDRegex == "" {
- r.DeployIDRegex = `^[A-Za-z0-9._-]{1,64}$`
- }
+ r.applyDefaults()
if r.CacheTTL <= 0 {
return fmt.Errorf("r2_alias: cache_ttl must be > 0 (got %s)", r.CacheTTL)
@@ -134,6 +158,9 @@ func (r *R2Alias) Validate() error {
if r.CacheMaxEntries <= 0 {
return fmt.Errorf("r2_alias: cache_max_entries must be > 0 (got %d)", r.CacheMaxEntries)
}
+ if r.FetchTimeout <= 0 {
+ return fmt.Errorf("r2_alias: fetch_timeout must be > 0 (got %s)", r.FetchTimeout)
+ }
re, err := regexp.Compile(r.DeployIDRegex)
if err != nil {
@@ -171,7 +198,10 @@ func (r *R2Alias) ServeHTTP(w http.ResponseWriter, req *http.Request, next caddy
zap.String("site", site),
zap.String("alias_name", aliasName),
}
- if errors.Is(resolveErr, errS3ServerError) {
+ // A deadline is an upstream-is-not-answering signal, not a bug here, so
+ // it answers 503 with Retry-After like any other upstream failure.
+ if errors.Is(resolveErr, errS3ServerError) ||
+ errors.Is(resolveErr, context.DeadlineExceeded) {
w.Header().Set("Retry-After", "30")
r.logger.Error("r2_alias upstream 5xx", fields...)
return caddyhttp.Error(http.StatusServiceUnavailable, resolveErr)
@@ -185,6 +215,7 @@ func (r *R2Alias) ServeHTTP(w http.ResponseWriter, req *http.Request, next caddy
}
if !r.deployIDRe.MatchString(entry.DeployID) ||
+ entry.DeployID == "." ||
strings.Contains(entry.DeployID, "..") ||
strings.ContainsRune(entry.DeployID, '/') {
r.logger.Warn("r2_alias deploy id rejected",
@@ -195,11 +226,11 @@ func (r *R2Alias) ServeHTTP(w http.ResponseWriter, req *http.Request, next caddy
return caddyhttp.Error(http.StatusNotFound, fmt.Errorf("r2_alias: deploy id rejected"))
}
- origPath := req.URL.Path
- if origPath == "" {
- origPath = "/"
- }
+ // Clean before the join: Caddy leaves req.URL.Path raw, and file_server's
+ // SanitizedPathJoin cleans too late to keep `..` inside the deploy prefix.
+ origPath := caddyhttp.CleanPath("/"+req.URL.Path, true)
req.URL.Path = "/" + site + "/deploys/" + entry.DeployID + origPath
+ req.URL.RawPath = ""
return next.ServeHTTP(w, req)
}
@@ -216,8 +247,7 @@ func (r *R2Alias) fetchAlias(ctx context.Context, cacheKey string) (aliasEntry,
Key: aws.String(s3Key),
})
if err != nil {
- var nsk *s3types.NoSuchKey
- if errors.As(err, &nsk) {
+ if isNoSuchKey(err) {
return aliasEntry{Present: false}, nil
}
var respErr *awshttp.ResponseError
diff --git a/docker/images/caddy-s3/modules/r2alias/r2alias_test.go b/caddy-r2alias/r2alias_test.go
similarity index 74%
rename from docker/images/caddy-s3/modules/r2alias/r2alias_test.go
rename to caddy-r2alias/r2alias_test.go
index dbff622ad..9c8c56fbb 100644
--- a/docker/images/caddy-s3/modules/r2alias/r2alias_test.go
+++ b/caddy-r2alias/r2alias_test.go
@@ -51,6 +51,9 @@ func TestValidate_DefaultsApplied(t *testing.T) {
if r.CacheMaxEntries != 10000 {
t.Errorf("CacheMaxEntries default: want 10000, got %d", r.CacheMaxEntries)
}
+ if r.FetchTimeout != 2*time.Second {
+ t.Errorf("FetchTimeout default: want 2s, got %s", r.FetchTimeout)
+ }
if r.PreviewSubdomain != "preview" {
t.Errorf("PreviewSubdomain default: want \"preview\", got %q", r.PreviewSubdomain)
}
@@ -82,6 +85,11 @@ func TestValidate_NegativeCacheParams(t *testing.T) {
R2Alias{Bucket: "b", Endpoint: "https://x", CacheMaxEntries: -1},
"cache_max_entries must be > 0",
},
+ {
+ "negative FetchTimeout",
+ R2Alias{Bucket: "b", Endpoint: "https://x", FetchTimeout: -1 * time.Second},
+ "fetch_timeout must be > 0",
+ },
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -114,6 +122,7 @@ func TestUnmarshalCaddyfile_FullBlock(t *testing.T) {
secret_access_key s
cache_ttl 15s
cache_max_entries 10000
+ fetch_timeout 2s
preview_subdomain "preview"
root_domain "freecode.camp"
deploy_id_regex "^[A-Za-z0-9._-]{1,64}$"
@@ -135,6 +144,9 @@ func TestUnmarshalCaddyfile_FullBlock(t *testing.T) {
if r.CacheMaxEntries != 10000 {
t.Errorf("CacheMaxEntries: want 10000, got %d", r.CacheMaxEntries)
}
+ if r.FetchTimeout != 2*time.Second {
+ t.Errorf("FetchTimeout: want 2s, got %s", r.FetchTimeout)
+ }
if r.PreviewSubdomain != "preview" {
t.Errorf("PreviewSubdomain mismatch: %q", r.PreviewSubdomain)
}
@@ -185,23 +197,26 @@ func newProvisionedForTest(t *testing.T) *R2Alias {
DeployIDRegex: `^[A-Za-z0-9._-]{1,64}$`,
CacheTTL: 1 * time.Second,
CacheMaxEntries: 10,
+ FetchTimeout: defaultFetchTimeout,
logger: zap.NewNop(),
}
r.deployIDRe = regexp.MustCompile(r.DeployIDRegex)
- r.cache = newAliasCache(r.CacheMaxEntries, r.CacheTTL)
+ r.cache = newAliasCache(r.CacheMaxEntries, r.CacheTTL, r.FetchTimeout)
return r
}
// capturingNext records the path the handler chain sees after the rewrite.
type capturingNext struct {
- called bool
- path string
+ called bool
+ path string
+ rawPath string
}
func (c *capturingNext) asHandler() caddyhttp.Handler {
return caddyhttp.HandlerFunc(func(_ http.ResponseWriter, req *http.Request) error {
c.called = true
c.path = req.URL.Path
+ c.rawPath = req.URL.RawPath
return nil
})
}
@@ -210,6 +225,23 @@ func stubFetcher(entry aliasEntry, err error) func(context.Context, string) (ali
return func(context.Context, string) (aliasEntry, error) { return entry, err }
}
+// blockingFetcher never returns until its context ends, so a test can observe
+// whichever deadline or cancellation fired first.
+func blockingFetcher(ctx context.Context, _ string) (aliasEntry, error) {
+ <-ctx.Done()
+ return aliasEntry{}, ctx.Err()
+}
+
+// newProvisionedWithFetchTimeout rebuilds the cache, because Provision reads
+// FetchTimeout once when it constructs it; setting the field alone does nothing.
+func newProvisionedWithFetchTimeout(t *testing.T, d time.Duration) *R2Alias {
+ t.Helper()
+ r := newProvisionedForTest(t)
+ r.FetchTimeout = d
+ r.cache = newAliasCache(r.CacheMaxEntries, r.CacheTTL, r.FetchTimeout)
+ return r
+}
+
func handlerStatus(t *testing.T, err error) int {
t.Helper()
if err == nil {
@@ -285,6 +317,51 @@ func TestServeHTTP_RootPathRewrite(t *testing.T) {
}
}
+func TestServeHTTP_RewriteResolvesDotSegments(t *testing.T) {
+ t.Parallel()
+ const prefix = "/site-a.freecode.camp/deploys/v1/"
+
+ cases := []struct {
+ name string
+ path string
+ want string
+ }{
+ {"parent segments", "/../../../other.freecode.camp/deploys/x/index.html", prefix + "other.freecode.camp/deploys/x/index.html"},
+ {"single parent", "/../staged-not-live/index.html", prefix + "staged-not-live/index.html"},
+ {"encoded parent", "/%2e%2e/%2e%2e/production", prefix + "production"},
+ {"interior parent", "/assets/../../../../etc/passwd", prefix + "etc/passwd"},
+ {"current-dir segment", "/./assets/x.js", prefix + "assets/x.js"},
+ {"empty segments", "//assets//x.js", prefix + "assets/x.js"},
+ {"trailing slash preserved", "/assets/", prefix + "assets/"},
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ t.Parallel()
+ r := newProvisionedForTest(t)
+ r.fetcher = stubFetcher(aliasEntry{DeployID: "v1", Present: true}, nil)
+
+ req := httptest.NewRequest(http.MethodGet, c.path, nil)
+ req.Host = "site-a.freecode.camp"
+ rec := httptest.NewRecorder()
+ next := &capturingNext{}
+
+ if err := r.ServeHTTP(rec, req, next.asHandler()); err != nil {
+ t.Fatalf("ServeHTTP: %v", err)
+ }
+ if next.path != c.want {
+ t.Fatalf("path rewrite: want %q, got %q", c.want, next.path)
+ }
+ if !strings.HasPrefix(next.path, prefix) {
+ t.Fatalf("escaped the deploy prefix: %q", next.path)
+ }
+ if next.rawPath != "" {
+ t.Fatalf("stale RawPath survives the rewrite: %q", next.rawPath)
+ }
+ })
+ }
+}
+
func TestServeHTTP_HostNotUnderRootDomain(t *testing.T) {
t.Parallel()
r := newProvisionedForTest(t)
@@ -333,6 +410,7 @@ func TestServeHTTP_DeployIDRejected(t *testing.T) {
name string
deployID string
}{
+ {"current-dir segment", "."},
{"contains dot-dot", "bad..name"},
{"contains slash", "v1/etc/passwd"},
{"over 64 chars", strings.Repeat("x", 65)},
@@ -414,3 +492,49 @@ func TestServeHTTP_PanicRecovered(t *testing.T) {
t.Fatalf("status: want 500 after panic recovery, got %d", got)
}
}
+
+func TestServeHTTP_FetchTimeoutIs503(t *testing.T) {
+ t.Parallel()
+ r := newProvisionedWithFetchTimeout(t, 20*time.Millisecond)
+ r.fetcher = blockingFetcher
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Host = "site-a.freecode.camp"
+ rec := httptest.NewRecorder()
+ next := &capturingNext{}
+
+ err := r.ServeHTTP(rec, req, next.asHandler())
+ if got := handlerStatus(t, err); got != http.StatusServiceUnavailable {
+ t.Fatalf("status: want 503, got %d", got)
+ }
+ if got := rec.Header().Get("Retry-After"); got != "30" {
+ t.Errorf("Retry-After: want 30, got %q", got)
+ }
+ if next.called {
+ t.Error("next handler must not run when the alias fetch times out")
+ }
+}
+
+func TestServeHTTP_FetchTimeoutDoesNotOutliveTheRequest(t *testing.T) {
+ t.Parallel()
+ r := newProvisionedWithFetchTimeout(t, time.Hour)
+ r.fetcher = blockingFetcher
+
+ reqCtx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(reqCtx)
+ req.Host = "site-a.freecode.camp"
+
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ _ = r.ServeHTTP(httptest.NewRecorder(), req, (&capturingNext{}).asHandler())
+ }()
+
+ select {
+ case <-done:
+ case <-time.After(2 * time.Second):
+ t.Fatal("a cancelled request must not wait for the fetch timeout")
+ }
+}
diff --git a/caddy-r2alias/s3stub_test.go b/caddy-r2alias/s3stub_test.go
new file mode 100644
index 000000000..8639729da
--- /dev/null
+++ b/caddy-r2alias/s3stub_test.go
@@ -0,0 +1,142 @@
+package r2alias_test
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+type stubObject struct {
+ body []byte
+ contentType string
+ modTime time.Time
+ // headSize, when non-zero, is the Content-Length a HEAD reports. It lets a
+ // test reproduce an object replaced between the HeadObject and the
+ // GetObject, where the two sizes disagree.
+ headSize int64
+}
+
+type s3Stub struct {
+ server *httptest.Server
+ bucket string
+
+ mu sync.RWMutex
+ objects map[string]stubObject
+ failStatus int
+ requests []string
+}
+
+func startS3Stub(t *testing.T) *s3Stub {
+ t.Helper()
+ s := &s3Stub{bucket: testBucket, objects: make(map[string]stubObject)}
+ s.server = httptest.NewServer(http.HandlerFunc(s.serve))
+ t.Cleanup(s.server.Close)
+ return s
+}
+
+func (s *s3Stub) endpoint() string { return s.server.URL }
+
+func (s *s3Stub) put(key, body, contentType string) {
+ s.putSkewed(key, body, contentType, 0)
+}
+
+// putSkewed stores an object whose HEAD reports headSize while its GET
+// delivers body.
+func (s *s3Stub) putSkewed(key, body, contentType string, headSize int64) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.objects[key] = stubObject{
+ body: []byte(body),
+ contentType: contentType,
+ modTime: time.Now().UTC().Truncate(time.Second),
+ headSize: headSize,
+ }
+}
+
+// setFailure makes every response a bodyless status, so the SDK yields a
+// generic error rather than a typed NoSuchKey.
+func (s *s3Stub) setFailure(status int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.failStatus = status
+}
+
+func (s *s3Stub) putAlias(site, aliasName, deployID string) {
+ s.put(site+"/"+aliasName, deployID, "text/plain")
+}
+
+// allOps returns every recorded " ", in order.
+func (s *s3Stub) allOps() []string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return append([]string(nil), s.requests...)
+}
+
+// opsFor returns every recorded " " for one key.
+func (s *s3Stub) opsFor(key string) []string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ var out []string
+ for _, op := range s.requests {
+ if strings.HasSuffix(op, " "+key) {
+ out = append(out, op)
+ }
+ }
+ return out
+}
+
+func (s *s3Stub) serve(w http.ResponseWriter, req *http.Request) {
+ key, ok := strings.CutPrefix(req.URL.Path, "/"+s.bucket+"/")
+
+ s.mu.Lock()
+ s.requests = append(s.requests, req.Method+" "+key)
+ fail := s.failStatus
+ obj, found := s.objects[key]
+ s.mu.Unlock()
+
+ if fail != 0 {
+ w.WriteHeader(fail)
+ return
+ }
+ if !ok {
+ writeS3Error(w, req, http.StatusNotFound, "NoSuchBucket", req.URL.Path)
+ return
+ }
+ if !found {
+ writeS3Error(w, req, http.StatusNotFound, "NoSuchKey", key)
+ return
+ }
+
+ if obj.contentType != "" {
+ w.Header().Set("Content-Type", obj.contentType)
+ }
+ size := int64(len(obj.body))
+ if req.Method == http.MethodHead && obj.headSize != 0 {
+ size = obj.headSize
+ }
+ w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
+ w.Header().Set("Last-Modified", obj.modTime.Format(http.TimeFormat))
+ w.WriteHeader(http.StatusOK)
+ if req.Method != http.MethodHead {
+ _, _ = w.Write(obj.body)
+ }
+}
+
+// The AWS SDK needs this XML shape to deserialize a typed *s3types.NoSuchKey;
+// a bare 404 only satisfies the generic ResponseError path, which fetchAlias
+// does not check.
+func writeS3Error(w http.ResponseWriter, req *http.Request, status int, code, key string) {
+ w.Header().Set("Content-Type", "application/xml")
+ w.WriteHeader(status)
+ if req.Method == http.MethodHead {
+ return
+ }
+ fmt.Fprintf(w, ``+
+ `%sThe specified key does not exist.`+
+ `%s`, code, key)
+}
diff --git a/docker/images/caddy-s3/modules/r2alias/testdata/site-a/deploys/v1/index.html b/caddy-r2alias/testdata/site-a/deploys/v1/index.html
similarity index 100%
rename from docker/images/caddy-s3/modules/r2alias/testdata/site-a/deploys/v1/index.html
rename to caddy-r2alias/testdata/site-a/deploys/v1/index.html
diff --git a/docker/images/caddy-s3/modules/r2alias/testdata/site-a/deploys/v2/index.html b/caddy-r2alias/testdata/site-a/deploys/v2/index.html
similarity index 100%
rename from docker/images/caddy-s3/modules/r2alias/testdata/site-a/deploys/v2/index.html
rename to caddy-r2alias/testdata/site-a/deploys/v2/index.html
diff --git a/caddy-r2alias/traversal_exploit_test.go b/caddy-r2alias/traversal_exploit_test.go
new file mode 100644
index 000000000..b0292e585
--- /dev/null
+++ b/caddy-r2alias/traversal_exploit_test.go
@@ -0,0 +1,48 @@
+package r2alias_test
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+)
+
+// TestIntegration_TraversalEscapesDeployPrefix asserts the serve plane cannot
+// read a bucket key outside the alias-selected deploy prefix.
+func TestIntegration_TraversalEscapesDeployPrefix(t *testing.T) {
+ stub := startS3Stub(t)
+ site := "site-a." + rootDomain
+ victim := "site-b." + rootDomain
+
+ uploadDeployFixtures(t, stub, site, "v1")
+ stub.putAlias(site, "production", "v1")
+
+ stub.put(victim+"/deploys/unfinalized/index.html", "SECRET-OTHER-SITE", "text/html")
+ stub.put("_trash/"+victim+"/dead-deploy/index.html", "SECRET-TRASHED", "text/html")
+ stub.put(site+"/deploys/staged-not-live/index.html", "SECRET-UNFINALIZED", "text/html")
+
+ tester := startCaddy(t, stub.endpoint())
+
+ cases := []struct {
+ name string
+ path string
+ secret string
+ }{
+ {"other site deploy", "/../../../" + victim + "/deploys/unfinalized/index.html", "SECRET-OTHER-SITE"},
+ {"trash", "/../../../_trash/" + victim + "/dead-deploy/index.html", "SECRET-TRASHED"},
+ {"own unfinalized deploy", "/../staged-not-live/index.html", "SECRET-UNFINALIZED"},
+ {"encoded dot segments", "/%2e%2e/%2e%2e/%2e%2e/" + victim + "/deploys/unfinalized/index.html", "SECRET-OTHER-SITE"},
+ {"alias object itself", "/../../production", "v1"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ status, body := doGet(t, tester, site, tc.path)
+ if strings.Contains(body, tc.secret) {
+ t.Fatalf("TRAVERSAL: served out-of-prefix key. status=%d body=%q", status, body)
+ }
+ if status != http.StatusNotFound {
+ t.Fatalf("status: want 404, got %d (body=%q)", status, body)
+ }
+ })
+ }
+}
diff --git a/docker/images/caddy-s3/Dockerfile b/docker/images/caddy-s3/Dockerfile
index 44607edc5..98f681cf5 100644
--- a/docker/images/caddy-s3/Dockerfile
+++ b/docker/images/caddy-s3/Dockerfile
@@ -6,10 +6,10 @@ ENV GOTOOLCHAIN=auto
# The in-tree r2alias package registers BOTH http.handlers.r2_alias and
# caddy.fs.r2 — no third-party Caddy plugins in this build.
-COPY modules/r2alias /src/modules/r2alias
+COPY caddy-r2alias /src/caddy-r2alias
RUN xcaddy build v2.11.3 \
- --with github.com/freeCodeCamp-Universe/infra/docker/images/caddy-s3/modules/r2alias=/src/modules/r2alias
+ --with github.com/freeCodeCamp-Universe/infra/caddy-r2alias=/src/caddy-r2alias
FROM caddy:2.11.3-alpine
diff --git a/docker/images/caddy-s3/README.md b/docker/images/caddy-s3/README.md
new file mode 100644
index 000000000..21f5fad87
--- /dev/null
+++ b/docker/images/caddy-s3/README.md
@@ -0,0 +1,76 @@
+# caddy-s3
+
+Caddy image for the `gxy-cassiopeia` static-serve plane. It serves every
+`*.freecode.camp` constellation site straight from the Cloudflare R2 bucket.
+
+Published as `ghcr.io/freecodecamp/caddy-s3`.
+
+## Contents
+
+The image is stock Caddy plus one in-tree plugin. It carries no third-party
+Caddy module (ADR D32).
+
+| Module | Purpose |
+| ------------------------ | -------------------------------------------------------------- |
+| `http.handlers.r2_alias` | Maps a Host header to a deploy, then rewrites the request path |
+| `caddy.fs.r2` | Reads object bytes from R2 |
+
+The plugin source is a separate Go module at
+[`caddy-r2alias/`](../../../caddy-r2alias) in this repo. This directory holds
+only the image.
+
+## How it serves a request
+
+1. `r2_alias` parses the Host header into a site and an alias name. The site
+ keeps the root domain. `hello.freecode.camp` gives site
+ `hello.freecode.camp` with alias `production`, and
+ `hello.preview.freecode.camp` gives the same site with alias `preview`.
+2. It reads the alias object `/` from R2. The body is a deploy ID.
+3. It rewrites the request path to `//deploys/`.
+ The visitor path is cleaned first, so a request can never leave the deploy.
+4. `file_server` reads that key through `caddy.fs.r2`.
+
+A deploy goes live when artemis writes a new deploy ID into the alias object.
+The alias cache holds the result for 15 seconds, so a flip takes effect within
+that window.
+
+## Build
+
+```sh
+just build-caddy-s3 # tags ghcr.io/freecodecamp/caddy-s3:dev-
+just verify-caddy-s3 # asserts both modules load and no third-party fs
+```
+
+The build context is the repository root, because the Dockerfile copies
+`caddy-r2alias/`. The root `.dockerignore` sends only that directory.
+
+GitHub Actions builds the canonical tag. The workflow is manual:
+
+```sh
+gh workflow run docker--caddy-s3.yml --ref
+```
+
+## Configuration
+
+The chart supplies the Caddyfile. See
+`k3s/gxy-cassiopeia/apps/caddy/charts/caddy/templates/configmap.yaml`.
+
+| Variable | Purpose |
+| ----------------------- | ------------------- |
+| `R2_BUCKET` | Bucket name |
+| `R2_ENDPOINT` | R2 S3 endpoint |
+| `AWS_ACCESS_KEY_ID` | Read-only R2 key |
+| `AWS_SECRET_ACCESS_KEY` | Read-only R2 secret |
+
+The key needs `GetObject` only. The plugin never lists the bucket.
+
+## Demo
+
+`demo/` runs the image against a local S3 mock with fixture sites. Start it
+with `docker compose up` from that directory.
+
+## Deploy
+
+Pin the new digest in
+`k3s/gxy-cassiopeia/apps/caddy/values.production.yaml`, then roll the chart
+with `just release gxy-cassiopeia caddy`.
diff --git a/docker/images/caddy-s3/demo/Caddyfile b/docker/images/caddy-s3/demo/Caddyfile
index 73d8c578b..b95433abf 100644
--- a/docker/images/caddy-s3/demo/Caddyfile
+++ b/docker/images/caddy-s3/demo/Caddyfile
@@ -7,7 +7,7 @@
filesystem r2 r2 {
bucket demo
- endpoint http://s3mock:9090
+ endpoint http://s3:9090
region us-east-1
access_key_id demo
secret_access_key demo
@@ -18,7 +18,7 @@
:80 {
r2_alias {
bucket demo
- endpoint http://s3mock:9090
+ endpoint http://s3:9090
region us-east-1
access_key_id demo
secret_access_key demo
diff --git a/docker/images/caddy-s3/demo/docker-compose.yaml b/docker/images/caddy-s3/demo/docker-compose.yaml
index 35947782d..163e8402a 100644
--- a/docker/images/caddy-s3/demo/docker-compose.yaml
+++ b/docker/images/caddy-s3/demo/docker-compose.yaml
@@ -1,38 +1,16 @@
services:
- s3mock:
- image: adobe/s3mock:5.0.0
- environment:
- COM_ADOBE_TESTING_S3MOCK_STORE_INITIAL_BUCKETS: demo
- ports:
- - "9090:9090"
- healthcheck:
- test:
- [
- "CMD-SHELL",
- "wget -qO- http://localhost:9090/ >/dev/null 2>&1 || exit 1",
- ]
- interval: 2s
- timeout: 2s
- retries: 15
-
- seed:
+ s3:
build:
- context: ./seed
- depends_on:
- s3mock:
- condition: service_healthy
+ context: ./s3
volumes:
- ./fixtures:/fixtures:ro
- # Re-run with: docker compose run --rm seed -alias v2
- command: ["-alias", "v1"]
caddy:
build:
- context: ..
- dockerfile: Dockerfile
+ context: ../../../..
+ dockerfile: docker/images/caddy-s3/Dockerfile
depends_on:
- seed:
- condition: service_completed_successfully
+ - s3
ports:
- "8080:80"
volumes:
diff --git a/docker/images/caddy-s3/demo/fixtures/v1/index.html b/docker/images/caddy-s3/demo/fixtures/demo.test.camp/deploys/v1/index.html
similarity index 92%
rename from docker/images/caddy-s3/demo/fixtures/v1/index.html
rename to docker/images/caddy-s3/demo/fixtures/demo.test.camp/deploys/v1/index.html
index 5af14061c..a00550198 100644
--- a/docker/images/caddy-s3/demo/fixtures/v1/index.html
+++ b/docker/images/caddy-s3/demo/fixtures/demo.test.camp/deploys/v1/index.html
@@ -45,7 +45,8 @@ Deploy v1
caddy.fs.r2 filesystem to stream this file from R2.
- Flip the alias with: docker compose run --rm seed -alias v2
+ Flip the alias with:
+ echo -n v2 > fixtures/demo.test.camp/production