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

diff --git a/docker/images/caddy-s3/demo/fixtures/v2/index.html b/docker/images/caddy-s3/demo/fixtures/demo.test.camp/deploys/v2/index.html similarity index 91% rename from docker/images/caddy-s3/demo/fixtures/v2/index.html rename to docker/images/caddy-s3/demo/fixtures/demo.test.camp/deploys/v2/index.html index 3a5dc55ee..8d9d71415 100644 --- a/docker/images/caddy-s3/demo/fixtures/v2/index.html +++ b/docker/images/caddy-s3/demo/fixtures/demo.test.camp/deploys/v2/index.html @@ -40,6 +40,9 @@

Deploy v2

the same bucket, same Caddy pod, no restart.

Cache TTL in the demo config is 2s. In prod it is 15s.

-

Flip back with: docker compose run --rm seed -alias v1

+

+ Flip back with: + echo -n v1 > fixtures/demo.test.camp/production +

diff --git a/docker/images/caddy-s3/demo/fixtures/demo.test.camp/preview b/docker/images/caddy-s3/demo/fixtures/demo.test.camp/preview new file mode 100644 index 000000000..8494ac270 --- /dev/null +++ b/docker/images/caddy-s3/demo/fixtures/demo.test.camp/preview @@ -0,0 +1 @@ +v2 \ No newline at end of file diff --git a/docker/images/caddy-s3/demo/fixtures/demo.test.camp/production b/docker/images/caddy-s3/demo/fixtures/demo.test.camp/production new file mode 100644 index 000000000..28c218c44 --- /dev/null +++ b/docker/images/caddy-s3/demo/fixtures/demo.test.camp/production @@ -0,0 +1 @@ +v1 \ No newline at end of file diff --git a/docker/images/caddy-s3/demo/s3/Dockerfile b/docker/images/caddy-s3/demo/s3/Dockerfile new file mode 100644 index 000000000..da3308267 --- /dev/null +++ b/docker/images/caddy-s3/demo/s3/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.26-alpine AS builder + +WORKDIR /src +COPY go.mod main.go ./ +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /s3 main.go + +FROM alpine:3.23 +RUN adduser -D -u 10001 s3 +COPY --from=builder /s3 /usr/local/bin/s3 +USER s3 +ENTRYPOINT ["/usr/local/bin/s3"] diff --git a/docker/images/caddy-s3/demo/s3/go.mod b/docker/images/caddy-s3/demo/s3/go.mod new file mode 100644 index 000000000..74f8e95fa --- /dev/null +++ b/docker/images/caddy-s3/demo/s3/go.mod @@ -0,0 +1,3 @@ +module github.com/freeCodeCamp-Universe/infra/docker/images/caddy-s3/demo/s3 + +go 1.26.2 diff --git a/docker/images/caddy-s3/demo/s3/main.go b/docker/images/caddy-s3/demo/s3/main.go new file mode 100644 index 000000000..52912a481 --- /dev/null +++ b/docker/images/caddy-s3/demo/s3/main.go @@ -0,0 +1,78 @@ +// Command s3 serves a directory tree as a read-only S3-compatible bucket. +// It answers the three operations the Caddy modules issue — GET, HEAD, and a +// NoSuchKey 404 — and nothing else. A key is a file; there are no directories. +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "os" + "path" + "path/filepath" + "strings" +) + +func main() { + root := flag.String("root", "/fixtures", "directory served as the bucket") + bucket := flag.String("bucket", "demo", "bucket name") + addr := flag.String("addr", ":9090", "listen address") + flag.Parse() + + srv := &server{root: *root, prefix: "/" + *bucket + "/"} + log.Printf("serving %s as bucket %q on %s", *root, *bucket, *addr) + if err := http.ListenAndServe(*addr, srv); err != nil { + log.Fatalf("listen: %v", err) + } +} + +type server struct { + root string + prefix string +} + +func (s *server) ServeHTTP(w http.ResponseWriter, req *http.Request) { + key, ok := strings.CutPrefix(req.URL.Path, s.prefix) + if !ok { + s.deny(w, req, "NoSuchBucket", req.URL.Path) + return + } + + rel := filepath.FromSlash(path.Clean("/" + key)[1:]) + if rel == "" || !filepath.IsLocal(rel) { + s.deny(w, req, "NoSuchKey", key) + return + } + + name := filepath.Join(s.root, rel) + info, err := os.Stat(name) + if err != nil || info.IsDir() { + s.deny(w, req, "NoSuchKey", key) + return + } + + file, err := os.Open(name) + if err != nil { + s.deny(w, req, "NoSuchKey", key) + return + } + defer func() { _ = file.Close() }() + + log.Printf("%s %s -> 200 (%d bytes)", req.Method, key, info.Size()) + http.ServeContent(w, req, info.Name(), info.ModTime(), file) +} + +// The AWS SDK needs this XML shape to raise a typed NoSuchKey. A bodyless 404 +// only reaches its generic error path. +func (s *server) deny(w http.ResponseWriter, req *http.Request, code, key string) { + log.Printf("%s %s -> 404 %s", req.Method, key, code) + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusNotFound) + 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/demo/seed/Dockerfile b/docker/images/caddy-s3/demo/seed/Dockerfile deleted file mode 100644 index a101c1d94..000000000 --- a/docker/images/caddy-s3/demo/seed/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM golang:1.26-alpine AS builder - -WORKDIR /src -COPY go.mod go.sum ./ -RUN go mod download -COPY main.go ./ -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /seed main.go - -FROM alpine:3.23 -RUN adduser -D -u 10001 seed -COPY --from=builder /seed /usr/local/bin/seed -USER seed -ENTRYPOINT ["/usr/local/bin/seed"] diff --git a/docker/images/caddy-s3/demo/seed/go.mod b/docker/images/caddy-s3/demo/seed/go.mod deleted file mode 100644 index 5729ea9e7..000000000 --- a/docker/images/caddy-s3/demo/seed/go.mod +++ /dev/null @@ -1,27 +0,0 @@ -module github.com/freeCodeCamp-Universe/infra/docker/images/caddy-s3/demo/seed - -go 1.26.2 - -require ( - github.com/aws/aws-sdk-go-v2 v1.41.6 - github.com/aws/aws-sdk-go-v2/config v1.32.16 - github.com/aws/aws-sdk-go-v2/credentials v1.19.15 - github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1 -) - -require ( - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect - github.com/aws/smithy-go v1.25.0 // indirect -) diff --git a/docker/images/caddy-s3/demo/seed/go.sum b/docker/images/caddy-s3/demo/seed/go.sum deleted file mode 100644 index cf65efa9b..000000000 --- a/docker/images/caddy-s3/demo/seed/go.sum +++ /dev/null @@ -1,36 +0,0 @@ -github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg= -github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9/go.mod h1:uOYhgfgThm/ZyAuJGNQ5YgNyOlYfqnGpTHXvk3cpykg= -github.com/aws/aws-sdk-go-v2/config v1.32.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM= -github.com/aws/aws-sdk-go-v2/config v1.32.16/go.mod h1:duCCnJEFqpt2RC6no1iK6q+8HpwOAkiUua0pY507dQc= -github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4= -github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 h1:IOGsJ1xVWhsi+ZO7/NW8OuZZBtMJLZbk4P5HDjJO0jQ= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22/go.mod h1:b+hYdbU+jGKfXE8kKM6g1+h+L/Go3vMvzlxBsiuGsxg= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 h1:xnvDEnw+pnj5mctWiYuFbigrEzSm35x7k4KS/ZkCANg= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14/go.mod h1:yS5rNogD8e0Wu9+l3MUwr6eENBzEeGejvINpN5PAYfY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 h1:SE+aQ4DEqG53RRCAIHlCf//B2ycxGH7jFkpnAh/kKPM= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22/go.mod h1:ES3ynECd7fYeJIL6+oax+uIEljmfps0S70BaQzbMd/o= -github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1 h1:kU/eBN5+MWNo/LcbNa4hWDdN76hdcd7hocU5kvu7IsU= -github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1/go.mod h1:Fw9aqhJicIVee1VytBBjH+l+5ov6/PhbtIK/u3rt/ls= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.16/go.mod h1:CudnEVKRtLn0+3uMV0yEXZ+YZOKnAtUJ5DmDhilVnIw= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 h1:oK/njaL8GtyEihkWMD4k3VgHCT64RQKkZwh0DG5j8ak= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20/go.mod h1:JHs8/y1f3zY7U5WcuzoJ/yAYGYtNIVPKLIbp61euvmg= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo= -github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U= -github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= diff --git a/docker/images/caddy-s3/demo/seed/main.go b/docker/images/caddy-s3/demo/seed/main.go deleted file mode 100644 index 9bca7584d..000000000 --- a/docker/images/caddy-s3/demo/seed/main.go +++ /dev/null @@ -1,104 +0,0 @@ -// Command seed primes the demo S3Mock bucket with two deploys and a single -// atomic alias file, then exits. Re-run with -alias v2 (or v1) to demonstrate -// the production alias flip without restarting anything else. -package main - -import ( - "bytes" - "context" - "errors" - "flag" - "fmt" - "log" - "os" - "time" - - "github.com/aws/aws-sdk-go-v2/aws" - "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" -) - -const ( - bucket = "demo" - site = "demo.test.camp" -) - -func main() { - endpoint := flag.String("endpoint", "http://s3mock:9090", "S3-compatible endpoint") - alias := flag.String("alias", "v1", "alias target: v1 or v2") - flag.Parse() - - if *alias != "v1" && *alias != "v2" { - log.Fatalf("-alias must be v1 or v2, got %q", *alias) - } - - ctx := context.Background() - cfg, err := config.LoadDefaultConfig(ctx, - config.WithRegion("us-east-1"), - config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("demo", "demo", "")), - ) - if err != nil { - log.Fatalf("load aws config: %v", err) - } - client := s3.NewFromConfig(cfg, func(o *s3.Options) { - o.BaseEndpoint = aws.String(*endpoint) - o.UsePathStyle = true - }) - - if err := waitForBucket(ctx, client); err != nil { - log.Fatalf("bucket not ready: %v", err) - } - - // Upload both deploys so the flip lands on something real. - for _, v := range []string{"v1", "v2"} { - body, err := os.ReadFile("/fixtures/" + v + "/index.html") - if err != nil { - log.Fatalf("read fixture %s: %v", v, err) - } - if err := put(ctx, client, site+"/deploys/"+v+"/index.html", body, "text/html"); err != nil { - log.Fatalf("put deploy %s: %v", v, err) - } - fmt.Printf(" uploaded %s/deploys/%s/index.html (%d bytes)\n", site, v, len(body)) - } - - // Atomically point production at the requested version — same mechanic - // the Woodpecker pipeline uses in prod (single PutObject on the alias - // file). - if err := put(ctx, client, site+"/production", []byte(*alias), "text/plain"); err != nil { - log.Fatalf("put alias: %v", err) - } - fmt.Printf(" alias %s/production -> %s\n", site, *alias) - fmt.Println("seed complete.") -} - -func put(ctx context.Context, client *s3.Client, key string, body []byte, contentType string) error { - _, err := client.PutObject(ctx, &s3.PutObjectInput{ - Bucket: aws.String(bucket), - Key: aws.String(key), - Body: bytes.NewReader(body), - ContentType: aws.String(contentType), - }) - return err -} - -// waitForBucket polls HeadBucket until S3Mock is accepting requests. compose's -// service_healthy gate sometimes fires before Tomcat finishes warming up. -func waitForBucket(ctx context.Context, client *s3.Client) error { - deadline := time.Now().Add(30 * time.Second) - var lastErr error - for time.Now().Before(deadline) { - _, err := client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(bucket)}) - if err == nil { - return nil - } - var notFound *s3types.NotFound - if errors.As(err, ¬Found) { - return fmt.Errorf("bucket %q does not exist (check COM_ADOBE_TESTING_S3MOCK_STORE_INITIAL_BUCKETS on s3mock service)", bucket) - } - lastErr = err - time.Sleep(500 * time.Millisecond) - } - return fmt.Errorf("timed out waiting for bucket: %w", lastErr) -} diff --git a/docker/images/caddy-s3/modules/r2alias/cache.go b/docker/images/caddy-s3/modules/r2alias/cache.go deleted file mode 100644 index a8623f770..000000000 --- a/docker/images/caddy-s3/modules/r2alias/cache.go +++ /dev/null @@ -1,61 +0,0 @@ -package r2alias - -import ( - "context" - "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 -} - -func newAliasCache(size int, ttl time.Duration) *aliasCache { - return &aliasCache{ - lru: expirable.NewLRU[string, aliasEntry](size, nil, ttl), - } -} - -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. -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 - } - - result, err, _ := c.sf.Do(key, func() (any, error) { - // 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 - } - entry, ferr := fetchFn(ctx, key) - if ferr != nil { - return aliasEntry{}, ferr - } - c.lru.Add(key, entry) - return entry, nil - }) - if err != nil { - return aliasEntry{}, err - } - return result.(aliasEntry), nil -} diff --git a/docker/images/caddy-s3/modules/r2alias/integration_test.go b/docker/images/caddy-s3/modules/r2alias/integration_test.go deleted file mode 100644 index 3650480f9..000000000 --- a/docker/images/caddy-s3/modules/r2alias/integration_test.go +++ /dev/null @@ -1,308 +0,0 @@ -//go:build integration - -package r2alias_test - -import ( - "bytes" - "context" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/aws/aws-sdk-go-v2/aws" - "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" - "github.com/caddyserver/caddy/v2/caddytest" - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/wait" - - _ "github.com/freeCodeCamp-Universe/infra/docker/images/caddy-s3/modules/r2alias" -) - -// Pin Adobe S3Mock by major version tag (D30 — no :latest). -const s3MockImage = "adobe/s3mock:5.0.0" - -// testBucket matches the initial bucket provisioned by S3Mock on startup. -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 -) - -type s3Mock struct { - endpoint string - client *s3.Client - bucket string -} - -func startS3Mock(t *testing.T) *s3Mock { - t.Helper() - testcontainers.SkipIfProviderIsNotHealthy(t) - ctx := context.Background() - - req := testcontainers.GenericContainerRequest{ - ContainerRequest: testcontainers.ContainerRequest{ - Image: s3MockImage, - ExposedPorts: []string{"9090/tcp"}, - Env: map[string]string{ - "COM_ADOBE_TESTING_S3MOCK_STORE_INITIAL_BUCKETS": testBucket, - }, - WaitingFor: wait.ForListeningPort("9090/tcp").WithStartupTimeout(60 * time.Second), - }, - Started: true, - } - - container, err := testcontainers.GenericContainer(ctx, req) - if err != nil { - t.Fatalf("start s3mock container: %v", err) - } - t.Cleanup(func() { - if err := container.Terminate(context.Background()); err != nil { - t.Logf("terminate s3mock container: %v", err) - } - }) - - host, err := container.Host(ctx) - if err != nil { - t.Fatalf("container host: %v", err) - } - port, err := container.MappedPort(ctx, "9090/tcp") - if err != nil { - t.Fatalf("mapped port: %v", err) - } - endpoint := fmt.Sprintf("http://%s:%s", host, port.Port()) - - awsCfg, err := config.LoadDefaultConfig(ctx, - config.WithRegion("us-east-1"), - config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), - ) - if err != nil { - t.Fatalf("aws config: %v", err) - } - client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { - o.BaseEndpoint = aws.String(endpoint) - o.UsePathStyle = true - }) - - return &s3Mock{endpoint: endpoint, client: client, bucket: testBucket} -} - -// uploadDeployFixtures uploads testdata/site-a/deploys//* to the -// bucket under /deploys//*. The disk layout is independent of -// the S3 prefix so one fixture set can back multiple site names. -func uploadDeployFixtures(t *testing.T, client *s3.Client, bucket, site, version string) { - t.Helper() - ctx := context.Background() - - 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 - } - - key := fmt.Sprintf("%s/deploys/%s/%s", site, version, rel) - _, putErr := client.PutObject(ctx, &s3.PutObjectInput{ - Bucket: aws.String(bucket), - Key: aws.String(key), - Body: bytes.NewReader(body), - ContentType: aws.String("text/html"), - }) - return putErr - }) - if err != nil { - t.Fatalf("upload fixtures %s/%s: %v", site, version, err) - } -} - -func putAlias(t *testing.T, client *s3.Client, bucket, site, aliasName, deployID string) { - t.Helper() - _, err := client.PutObject(context.Background(), &s3.PutObjectInput{ - Bucket: aws.String(bucket), - Key: aws.String(fmt.Sprintf("%s/%s", site, aliasName)), - Body: strings.NewReader(deployID), - }) - if err != nil { - t.Fatalf("put alias %s/%s=%s: %v", site, aliasName, deployID, err) - } -} - -func startCaddy(t *testing.T, s3mockEndpoint 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 - root_domain %s - } - file_server { - fs r2 - } -} -`, - caddyAdminPort, caddyHTTPPort, caddyHTTPSPort, - testBucket, s3mockEndpoint, - caddyHTTPPort, - testBucket, s3mockEndpoint, - 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() - 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.StatusCode, 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) { - mock := startS3Mock(t) - site := "site-a." + rootDomain - - uploadDeployFixtures(t, mock.client, mock.bucket, site, "v1") - putAlias(t, mock.client, mock.bucket, site, "production", "v1") - - tester := startCaddy(t, mock.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) { - mock := startS3Mock(t) - site := "site-a." + rootDomain - - uploadDeployFixtures(t, mock.client, mock.bucket, site, "v1") - uploadDeployFixtures(t, mock.client, mock.bucket, site, "v2") - putAlias(t, mock.client, mock.bucket, site, "production", "v1") - - tester := startCaddy(t, mock.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") - - putAlias(t, mock.client, mock.bucket, 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) { - mock := startS3Mock(t) - prodSite := "site-a." + rootDomain - previewHost := "site-a.preview." + rootDomain - - uploadDeployFixtures(t, mock.client, mock.bucket, prodSite, "v2") - putAlias(t, mock.client, mock.bucket, prodSite, "preview", "v2") - - tester := startCaddy(t, mock.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_MissingSite404(t *testing.T) { - mock := startS3Mock(t) - tester := startCaddy(t, mock.endpoint) - - status, _ := doGet(t, tester, "dead."+rootDomain, "/") - if status != http.StatusNotFound { - t.Fatalf("status: want 404, got %d", status) - } -} diff --git a/docs/flight-manuals/gxy-cassiopeia.md b/docs/flight-manuals/gxy-cassiopeia.md index a7e76deb1..d2eac0ede 100644 --- a/docs/flight-manuals/gxy-cassiopeia.md +++ b/docs/flight-manuals/gxy-cassiopeia.md @@ -102,6 +102,17 @@ fi The recipe layers chart defaults → `values.production.yaml` (image SHA pin, hostnames, replicas) → sops overlay `caddy.values.yaml.enc` (R2 credentials). Image pulls from `ghcr.io/freecodecamp/caddy-s3:@sha256:` direct (build-residency principle — pillars build outside Universe; never through zot for chicken-egg avoidance). +**The image and the Caddyfile move together.** `charts/caddy/templates/configmap.yaml` holds the Caddyfile, and `templates/deployment.yaml` carries a `checksum/caddyfile` annotation, so any Caddyfile edit restarts all replicas at once. `r2_alias` and `caddy.fs.r2` reject an unknown sub-directive at parse time, so an image that predates a new directive refuses to start and every `*.freecode.camp` site goes down. Adding a directive therefore takes **two releases, never one**: roll the digest first, confirm all replicas are Running on it, then roll the Caddyfile change. One release applies both objects together, and any old-image pod that restarts mid-rollout picks up the already-synced new Caddyfile and crash-loops. A rollback runs the same two steps in reverse — revert the Caddyfile, then the digest, never the image alone. Validate before either roll: + +```bash +docker run --rm --platform linux/amd64 \ + -e R2_BUCKET=b -e R2_ENDPOINT=https://x \ + -e AWS_ACCESS_KEY_ID=k -e AWS_SECRET_ACCESS_KEY=s \ + -v /path/to/rendered/Caddyfile:/etc/caddy/Caddyfile:ro \ + ghcr.io/freecodecamp/caddy-s3@sha256: \ + caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile +``` + ### B.2 R2 bucket verify ```bash diff --git a/docs/infra-guides/caddy-s3-demo.md b/docs/infra-guides/caddy-s3-demo.md index 10583e425..0daf13c6e 100644 --- a/docs/infra-guides/caddy-s3-demo.md +++ b/docs/infra-guides/caddy-s3-demo.md @@ -1,12 +1,16 @@ # r2_alias demo -Self-contained demo of the `caddy.fs.r2` + `r2_alias` stack that powers `gxy-cassiopeia`. No cluster, no Cloudflare, no Go toolchain — just Docker. +Self-contained demo of the `caddy.fs.r2` + `r2_alias` stack that powers +`gxy-cassiopeia`. No cluster, no Cloudflare, no third-party image — just Docker. ## What it proves -1. Caddy reads an alias file from an S3-compatible bucket, rewrites the request path to the pinned deploy ID, and streams the object body back. -1. Flipping the alias is a single `PutObject` — the same mechanic the artemis deploy proxy uses for atomic promote / rollback in production. -1. Preview subdomains (`{site}--preview.test.camp`) route to a separate alias file while sharing the same deploy storage. +1. Caddy reads an alias file from an S3-compatible bucket, rewrites the request + path to the pinned deploy ID, and streams the object body back. +2. A promote or a rollback is one alias write. Nothing else moves. +3. A preview host resolves through a second alias while it shares the same + deploy storage. +4. A visitor cannot read outside the deploy the alias selected. ## Stand up @@ -21,61 +25,78 @@ Wait for `caddy-1 | ... server running`, then from another terminal: curl -H 'Host: demo.test.camp' http://localhost:8080/ ``` -You should see the **v1** page. +You get the **v1** page. -## Flip the alias (atomic promote) +## Flip the alias + +The bucket is the `fixtures/` directory, so an alias write is a file write: ```bash -docker compose run --rm seed -alias v2 +echo -n v2 > fixtures/demo.test.camp/production ``` -After the 2 s cache TTL expires: +After the 2 s cache TTL: ```bash curl -H 'Host: demo.test.camp' http://localhost:8080/ ``` -Now shows the **v2** page. Flip back with `-alias v1`. +You get the **v2** page. Write `v1` back to roll back. ## Other scenarios -- Missing site (nothing in the bucket for this host): +Preview routing. The `preview` alias already points at v2: - ```bash - curl -i -H 'Host: ghost.test.camp' http://localhost:8080/ - ``` +```bash +curl -H 'Host: demo.preview.test.camp' http://localhost:8080/ +``` - → `404` +Missing site: -- Preview routing — set a preview alias and request the preview host: +```bash +curl -i -H 'Host: ghost.test.camp' http://localhost:8080/ # 404 +``` - ```bash - docker compose run --rm seed -alias v2 # make sure v2 is the active alias - # (preview uses the same deploy storage; this demo does not set a separate - # preview alias, so the preview host returns 404 unless you extend seed to - # write demo.test.camp/preview) - ``` +Deploy containment. The visitor path is cleaned before it is joined to the +deploy prefix, so no request escapes: + +```bash +curl -i --path-as-is -H 'Host: demo.test.camp' \ + 'http://localhost:8080/../../production' # 404 +``` + +Watch the object operations while you do any of the above: + +```bash +docker compose logs -f s3 +``` ## Tear down ```bash -docker compose down -v +docker compose down ``` ## Layout -- `docker-compose.yaml` — wires S3Mock + seed + the `caddy-s3` image -- `Caddyfile` — the same module layout the production chart uses, minus TLS -- `fixtures/v1,v2/index.html` — two deploys that the seeder uploads -- `seed/` — tiny Go binary that uses AWS SDK v2 (same SDK as the Caddy module) to seed the bucket and flip the alias +- `docker-compose.yaml` — wires the local S3 server to the `caddy-s3` image +- `Caddyfile` — the module layout the production chart uses, minus TLS +- `s3/` — a stdlib-only Go server that serves `fixtures/` as a read-only + bucket. It answers GET, HEAD, and a `NoSuchKey` 404, which is every + operation the Caddy modules issue +- `fixtures/demo.test.camp/` — the bucket contents: two deploys under + `deploys/`, plus the `production` and `preview` alias files ## Production parity | Concern | Demo | Prod | | ----------------- | ------------------------- | ------------------------------------ | -| Object storage | Adobe S3Mock container | Cloudflare R2 | -| Alias write | seed container PutObject | artemis PutObject | -| Caddy credentials | `demo`/`demo` (any value) | org-scoped RO key from infra-secrets | +| Object storage | Local Go server over disk | Cloudflare R2 | +| Alias write | Edit the alias file | artemis PutObject | +| Caddy credentials | `demo`/`demo` (unchecked) | org-scoped RO key from infra-secrets | | Alias cache TTL | 2 s | 15 s | | Root domain | `test.camp` | `freecode.camp` | | Front-door TLS | off | Cloudflare CDN in front | + +The demo server does not check the request signature. Everything else in the +request path is the production code. diff --git a/justfile b/justfile index 13505f63c..f97040137 100644 --- a/justfile +++ b/justfile @@ -681,7 +681,8 @@ build-caddy-s3: --platform linux/amd64 \ --load \ -t "ghcr.io/freecodecamp/caddy-s3:${TAG}" \ - docker/images/caddy-s3/ + -f docker/images/caddy-s3/Dockerfile \ + . echo "Built: ghcr.io/freecodecamp/caddy-s3:${TAG} (linux/amd64)" # Build the postgres-rclone image locally (postgres:18-bookworm + baked diff --git a/k3s/gxy-cassiopeia/apps/caddy/charts/caddy/templates/configmap.yaml b/k3s/gxy-cassiopeia/apps/caddy/charts/caddy/templates/configmap.yaml index 2402225c9..32ba9716b 100644 --- a/k3s/gxy-cassiopeia/apps/caddy/charts/caddy/templates/configmap.yaml +++ b/k3s/gxy-cassiopeia/apps/caddy/charts/caddy/templates/configmap.yaml @@ -20,6 +20,11 @@ data: access_key_id {$AWS_ACCESS_KEY_ID} secret_access_key {$AWS_SECRET_ACCESS_KEY} use_path_style + # Code defaults, not set here. Truth lives in the named constants in + # caddy-r2alias/filesystem.go. + # max_file_size 100 MiB defaultMaxFileSize + # A larger object answers the visitor 400, not 413. + # (no knob) 30s opTimeout, bounds every S3 round trip } } @@ -46,6 +51,12 @@ data: secret_access_key {$AWS_SECRET_ACCESS_KEY} cache_ttl 15s cache_max_entries 10000 + # Code defaults, not set here. Truth lives in the named constants in + # caddy-r2alias/r2alias.go; grep them before trusting these numbers. + # fetch_timeout 2s defaultFetchTimeout + # deploy_id_regex ^[A-Za-z0-9._-]{1,64}$ defaultDeployIDRegex + # These are comments, not directives. An image that predates a + # directive refuses to start and takes every site down. preview_subdomain "preview" root_domain "freecode.camp" } diff --git a/k3s/gxy-management/apps/artemis/values.production.yaml b/k3s/gxy-management/apps/artemis/values.production.yaml index 5cb77009c..b4939c744 100644 --- a/k3s/gxy-management/apps/artemis/values.production.yaml +++ b/k3s/gxy-management/apps/artemis/values.production.yaml @@ -49,7 +49,7 @@ env: # site = ` + .` (host.go). Without the suffix # caddy reads `test.freecode.camp/preview` while artemis writes # `test/preview` → 404. Verified 2026-04-27 against - # docker/images/caddy-s3/modules/r2alias/host.go. + # caddy-r2alias/host.go. ALIAS_PRODUCTION_KEY_FORMAT: ".freecode.camp/production" ALIAS_PREVIEW_KEY_FORMAT: ".freecode.camp/preview" DEPLOY_PREFIX_FORMAT: ".freecode.camp/deploys/-/"