Go assets Service — Static Media CDN Migration — Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Serve the heavy static media prefixes (/library, /music, /models, /sprites) from the S3-compatible bucket through a new dedicated Go assets service with HTTP Range support, fronted by the gateway and edge-cached by Cloudflare; sync the bytes from the repo at deploy time so the runtime image stops shipping ~720 MB.
Architecture: A new go-services/cmd/assets binary streams objects from the bucket via a new range-aware go-services/pkg/objectstore client (the Go analog of the TS lib/storage/s3.server.ts seam). The gateway proxies the four prefixes to it like the WS services. A mc mirror deploy step uploads public/{library,music,models,sprites} to the bucket. Cutover is phased and reversible (re-point one prefix at a time; rollback = one line).
Tech Stack: Go 1.23 (built/tested via Docker — go-services/Dockerfile, golang:1.23-alpine), aws-sdk-go-v2/s3, the existing pkg/{config,httpx,log} helpers, Helm (deploy/helm/rmhstudios-go) + deploy/deploy-go.sh, MinIO (→ R2), mc/rclone.
Global Constraints¶
Go module is
github.com/rmhstudios/rmh-go; services live undergo-services/cmd/<svc>+go-services/internal/<svc>; shared code undergo-services/pkg/<pkg>. Follow thecmd/recap+internal/recapshape.Config is read via
pkg/config(config.GetString(key, fallback),config.GetInt); servers usepkg/httpx(httpx.NewServer(addr, handler, logger).Run(grace),httpx.Health(service, ready)); logging viapkg/log(log.New(service, level)).The four prefixes and their bucket key roots are:
/library/→library/,/music/→music/,/models/→models/,/sprites/→sprites/, in the same bucket as user uploads (S3_BUCKET), namespaced apart fromrmharks/.S3 config env (already defined for the feed feature):
S3_ENDPOINT,S3_REGION,S3_ACCESS_KEY_ID,S3_SECRET_ACCESS_KEY,S3_BUCKET,S3_FORCE_PATH_STYLE(path-style when!= "false").Cache headers must match Apache today:
Cache-Control: public, max-age=86400, stale-while-revalidate=604800andAccept-Ranges: bytes; pass throughETag/Last-Modified.New service port:
ASSETS_PORTdefault7007. Gateway upstream env:ASSETS_UPSTREAMdefaulthttp://assets:7007.Build/test runs on Docker, not Bazel (the project is staying on the Docker path for now). There is no local Go toolchain — compile and test through a Go container. Canonical command (the
rmhgo-modcachevolume keeps re-runs fast):docker run --rm -v "$(git rev-parse --show-toplevel)/go-services":/src -w /src -v rmhgo-modcache:/go/pkg/mod golang:1.23-alpine go test ./<pkg>/...Images are built by
go-services/Dockerfile(generic overSERVICE→ compiles./cmd/$SERVICE) and deployed bydeploy/deploy-go.sh; a new service is registered by adding it to that script’sSERVICESarray. Do not add or maintain BazelBUILD.bazel/go_service_imageentries or rungazelle— if the project returns to Bazel later,make gazelleregenerates them in one pass.Adding a Go dependency: edit
go-services/go.mod’srequireblock, then rungo mod tidyin the container:docker run --rm -v "$(git rev-parse --show-toplevel)/go-services":/src -w /src -v rmhgo-modcache:/go/pkg/mod golang:1.23-alpine go mod tidy.
Task 1: pkg/objectstore — range-aware S3 client¶
Files:
Modify:
go-services/go.mod(addaws-sdk-go-v2deps)Create:
go-services/pkg/objectstore/objectstore.goTest:
go-services/pkg/objectstore/objectstore_test.go
Interfaces:
Produces:
type Object struct { Body io.ReadCloser; ContentType string; ContentLength int64; ContentRange string; ETag string; LastModified string; Status int }var ErrNotFound = errors.New("objectstore: not found")var ErrRangeNotSatisfiable = errors.New("objectstore: range not satisfiable")type S3 struct { ... }withfunc New(ctx context.Context) (*S3, error)(readsS3_*env) andfunc (s *S3) Get(ctx context.Context, key, rangeHeader string) (*Object, error).An interface consumers can mock: callers depend on
Get(ctx, key, rangeHeader string) (*Object, error).
Step 1: Add the AWS SDK dependency
Edit go-services/go.mod to add to the require block:
github.com/aws/aws-sdk-go-v2 v1.30.3
github.com/aws/aws-sdk-go-v2/config v1.27.27
github.com/aws/aws-sdk-go-v2/credentials v1.17.27
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2
Then resolve modules in a Go container:
cd "$(git rev-parse --show-toplevel)"
docker run --rm -v "$PWD/go-services":/src -w /src -v rmhgo-modcache:/go/pkg/mod golang:1.23-alpine go mod tidy
Expected: go-services/go.sum gains the aws-sdk-go-v2 modules.
Step 2: Write the failing test
// go-services/pkg/objectstore/objectstore_test.go
package objectstore
import (
"errors"
"testing"
)
func TestParseConfigDefaults(t *testing.T) {
t.Setenv("S3_ENDPOINT", "http://minio:9000")
t.Setenv("S3_REGION", "us-east-1")
t.Setenv("S3_ACCESS_KEY_ID", "k")
t.Setenv("S3_SECRET_ACCESS_KEY", "s")
t.Setenv("S3_BUCKET", "rmh-media")
t.Setenv("S3_FORCE_PATH_STYLE", "true")
cfg, err := loadConfig()
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
if cfg.bucket != "rmh-media" {
t.Errorf("bucket = %q, want rmh-media", cfg.bucket)
}
if !cfg.forcePathStyle {
t.Errorf("forcePathStyle = false, want true")
}
}
func TestLoadConfigMissingBucket(t *testing.T) {
t.Setenv("S3_ENDPOINT", "http://minio:9000")
t.Setenv("S3_BUCKET", "")
if _, err := loadConfig(); err == nil {
t.Fatal("expected error for missing S3_BUCKET")
}
}
func TestSentinelsDistinct(t *testing.T) {
if errors.Is(ErrNotFound, ErrRangeNotSatisfiable) {
t.Fatal("sentinels must be distinct")
}
}
Step 3: Run the test to verify it fails
Run: docker run --rm -v "$(git rev-parse --show-toplevel)/go-services":/src -w /src -v rmhgo-modcache:/go/pkg/mod golang:1.23-alpine go test ./pkg/objectstore/...
Expected: FAIL (compile error) — loadConfig, ErrNotFound, ErrRangeNotSatisfiable undefined.
Step 4: Write the implementation
// go-services/pkg/objectstore/objectstore.go
//
// objectstore is the Go analog of the TS lib/storage/s3.server.ts seam: a thin,
// range-aware reader over an S3-compatible bucket (MinIO now, R2 later). It is
// the ONLY place in go-services that imports the AWS SDK.
package objectstore
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"github.com/aws/aws-sdk-go-v2/aws"
awscfg "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/aws/aws-sdk-go-v2/service/s3/types"
)
// ErrNotFound is returned by Get when the key does not exist.
var ErrNotFound = errors.New("objectstore: not found")
// ErrRangeNotSatisfiable is returned when the requested Range cannot be served.
var ErrRangeNotSatisfiable = errors.New("objectstore: range not satisfiable")
// Object is a streamable object plus the metadata needed to relay it to a client.
type Object struct {
Body io.ReadCloser
ContentType string
ContentLength int64
ContentRange string // set on a 206 partial response
ETag string
LastModified string
Status int // 200 or 206
}
type config struct {
endpoint, region, accessKey, secretKey, bucket string
forcePathStyle bool
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func loadConfig() (config, error) {
c := config{
endpoint: os.Getenv("S3_ENDPOINT"),
region: envOr("S3_REGION", "us-east-1"),
accessKey: os.Getenv("S3_ACCESS_KEY_ID"),
secretKey: os.Getenv("S3_SECRET_ACCESS_KEY"),
bucket: os.Getenv("S3_BUCKET"),
forcePathStyle: os.Getenv("S3_FORCE_PATH_STYLE") != "false",
}
if c.bucket == "" {
return config{}, errors.New("objectstore: S3_BUCKET is required")
}
if c.endpoint == "" {
return config{}, errors.New("objectstore: S3_ENDPOINT is required")
}
return c, nil
}
// S3 reads objects from an S3-compatible bucket.
type S3 struct {
client *s3.Client
bucket string
}
// New builds an S3 reader from the S3_* environment.
func New(ctx context.Context) (*S3, error) {
c, err := loadConfig()
if err != nil {
return nil, err
}
awsConf, err := awscfg.LoadDefaultConfig(ctx,
awscfg.WithRegion(c.region),
awscfg.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(c.accessKey, c.secretKey, ""),
),
)
if err != nil {
return nil, fmt.Errorf("objectstore: aws config: %w", err)
}
client := s3.NewFromConfig(awsConf, func(o *s3.Options) {
o.BaseEndpoint = aws.String(c.endpoint)
o.UsePathStyle = c.forcePathStyle
})
return &S3{client: client, bucket: c.bucket}, nil
}
// Get streams the object at key. If rangeHeader is non-empty it is forwarded to
// S3 and a 206 partial may be returned. Missing keys yield ErrNotFound; an
// unsatisfiable range yields ErrRangeNotSatisfiable.
func (s *S3) Get(ctx context.Context, key, rangeHeader string) (*Object, error) {
in := &s3.GetObjectInput{Bucket: aws.String(s.bucket), Key: aws.String(key)}
if rangeHeader != "" {
in.Range = aws.String(rangeHeader)
}
out, err := s.client.GetObject(ctx, in)
if err != nil {
var nsk *types.NoSuchKey
if errors.As(err, &nsk) {
return nil, ErrNotFound
}
var apiErr interface{ ErrorCode() string }
if errors.As(err, &apiErr) {
switch apiErr.ErrorCode() {
case "NoSuchKey", "NotFound":
return nil, ErrNotFound
case "InvalidRange":
return nil, ErrRangeNotSatisfiable
}
}
return nil, fmt.Errorf("objectstore: get %q: %w", key, err)
}
obj := &Object{
Body: out.Body,
Status: http.StatusOK,
}
if out.ContentType != nil {
obj.ContentType = *out.ContentType
}
if out.ContentLength != nil {
obj.ContentLength = *out.ContentLength
}
if out.ETag != nil {
obj.ETag = *out.ETag
}
if out.LastModified != nil {
obj.LastModified = out.LastModified.UTC().Format(http.TimeFormat)
}
if out.ContentRange != nil {
obj.ContentRange = *out.ContentRange
obj.Status = http.StatusPartialContent
}
return obj, nil
}
Step 5: Run the test to verify it passes
Run: docker run --rm -v "$(git rev-parse --show-toplevel)/go-services":/src -w /src -v rmhgo-modcache:/go/pkg/mod golang:1.23-alpine go test ./pkg/objectstore/...
Expected: PASS (3 tests).
Step 6: Commit
git add go-services/go.mod go-services/go.sum go-services/pkg/objectstore
git commit -m "feat(go): objectstore — range-aware S3 reader for go-services"
Task 2: internal/assets — the HTTP handler¶
Files:
Create:
go-services/internal/assets/handler.goTest:
go-services/internal/assets/handler_test.go
Interfaces:
Consumes:
objectstore.Object,objectstore.ErrNotFound,objectstore.ErrRangeNotSatisfiable(Task 1).Produces:
type Store interface { Get(ctx context.Context, key, rangeHeader string) (*objectstore.Object, error) }func NewHandler(store Store, logger *log.Logger) http.HandlerAllowed prefixes constant set:
library,music,models,sprites.
Step 1: Write the failing test
// go-services/internal/assets/handler_test.go
package assets
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/rmhstudios/rmh-go/pkg/log"
"github.com/rmhstudios/rmh-go/pkg/objectstore"
)
type fakeStore struct {
gotKey, gotRange string
obj *objectstore.Object
err error
}
func (f *fakeStore) Get(_ context.Context, key, rng string) (*objectstore.Object, error) {
f.gotKey, f.gotRange = key, rng
return f.obj, f.err
}
func body(s string) io.ReadCloser { return io.NopCloser(strings.NewReader(s)) }
func newReq(method, path, rng string) *http.Request {
r := httptest.NewRequest(method, path, nil)
if rng != "" {
r.Header.Set("Range", rng)
}
return r
}
func TestServesObjectWithCacheHeaders(t *testing.T) {
fs := &fakeStore{obj: &objectstore.Object{
Body: body("PDFDATA"), ContentType: "application/pdf",
ContentLength: 7, ETag: `"abc"`, Status: http.StatusOK,
}}
h := NewHandler(fs, log.New("assets", "error"))
w := httptest.NewRecorder()
h.ServeHTTP(w, newReq("GET", "/library/book.pdf", ""))
if fs.gotKey != "library/book.pdf" {
t.Errorf("key = %q, want library/book.pdf", fs.gotKey)
}
if w.Code != 200 {
t.Errorf("status = %d, want 200", w.Code)
}
if got := w.Header().Get("Cache-Control"); got != "public, max-age=86400, stale-while-revalidate=604800" {
t.Errorf("Cache-Control = %q", got)
}
if w.Header().Get("Accept-Ranges") != "bytes" {
t.Errorf("missing Accept-Ranges")
}
if w.Body.String() != "PDFDATA" {
t.Errorf("body = %q", w.Body.String())
}
}
func TestForwardsRangeAndReturns206(t *testing.T) {
fs := &fakeStore{obj: &objectstore.Object{
Body: body("DATA"), ContentType: "audio/mpeg",
ContentRange: "bytes 0-3/100", Status: http.StatusPartialContent,
}}
h := NewHandler(fs, log.New("assets", "error"))
w := httptest.NewRecorder()
h.ServeHTTP(w, newReq("GET", "/music/song.mp3", "bytes=0-3"))
if fs.gotRange != "bytes=0-3" {
t.Errorf("range forwarded = %q, want bytes=0-3", fs.gotRange)
}
if w.Code != http.StatusPartialContent {
t.Errorf("status = %d, want 206", w.Code)
}
if w.Header().Get("Content-Range") != "bytes 0-3/100" {
t.Errorf("Content-Range = %q", w.Header().Get("Content-Range"))
}
}
func TestRejectsUnknownPrefix(t *testing.T) {
fs := &fakeStore{}
h := NewHandler(fs, log.New("assets", "error"))
w := httptest.NewRecorder()
h.ServeHTTP(w, newReq("GET", "/secrets/passwd", ""))
if w.Code != 404 {
t.Errorf("status = %d, want 404", w.Code)
}
if fs.gotKey != "" {
t.Errorf("store should not be called for bad prefix, got key %q", fs.gotKey)
}
}
func TestRejectsTraversal(t *testing.T) {
fs := &fakeStore{}
h := NewHandler(fs, log.New("assets", "error"))
for _, p := range []string{"/library/../secrets", "/library/%2e%2e/secrets"} {
w := httptest.NewRecorder()
h.ServeHTTP(w, newReq("GET", p, ""))
if w.Code != 404 {
t.Errorf("path %q: status = %d, want 404", p, w.Code)
}
}
if fs.gotKey != "" {
t.Errorf("store should not be called on traversal, got key %q", fs.gotKey)
}
}
func TestMissingKeyReturns404(t *testing.T) {
fs := &fakeStore{err: objectstore.ErrNotFound}
h := NewHandler(fs, log.New("assets", "error"))
w := httptest.NewRecorder()
h.ServeHTTP(w, newReq("GET", "/models/missing.glb", ""))
if w.Code != 404 {
t.Errorf("status = %d, want 404", w.Code)
}
}
func TestBadRangeReturns416(t *testing.T) {
fs := &fakeStore{err: objectstore.ErrRangeNotSatisfiable}
h := NewHandler(fs, log.New("assets", "error"))
w := httptest.NewRecorder()
h.ServeHTTP(w, newReq("GET", "/music/song.mp3", "bytes=999999-"))
if w.Code != http.StatusRequestedRangeNotSatisfiable {
t.Errorf("status = %d, want 416", w.Code)
}
}
Step 2: Run the test to verify it fails
Run: docker run --rm -v "$(git rev-parse --show-toplevel)/go-services":/src -w /src -v rmhgo-modcache:/go/pkg/mod golang:1.23-alpine go test ./internal/assets/...
Expected: FAIL (compile error) — NewHandler / Store undefined.
Step 3: Write the implementation
// go-services/internal/assets/handler.go
//
// Package assets serves the heavy static media prefixes (/library, /music,
// /models, /sprites) by streaming objects from the bucket, replacing the
// Apache-off-disk "self-hosted CDN". Range requests are passed through for
// audio seeking and large PDFs.
package assets
import (
"context"
"errors"
"io"
"net/http"
"path"
"strings"
"github.com/rmhstudios/rmh-go/pkg/log"
"github.com/rmhstudios/rmh-go/pkg/objectstore"
)
// cacheControl mirrors the Apache vhost policy these assets are served with today.
const cacheControl = "public, max-age=86400, stale-while-revalidate=604800"
// allowedPrefixes is the set of top-level path segments this service owns.
var allowedPrefixes = map[string]struct{}{
"library": {}, "music": {}, "models": {}, "sprites": {},
}
// Store is the read dependency (satisfied by *objectstore.S3).
type Store interface {
Get(ctx context.Context, key, rangeHeader string) (*objectstore.Object, error)
}
type handler struct {
store Store
logger *log.Logger
}
// NewHandler returns an http.Handler serving the four media prefixes from store.
func NewHandler(store Store, logger *log.Logger) http.Handler {
return &handler{store: store, logger: logger}
}
// keyForPath validates the request path and maps it to a bucket key, or returns
// ok=false if the path is not an allowed, traversal-free media path.
func keyForPath(urlPath string) (key string, ok bool) {
clean := path.Clean(urlPath)
if !strings.HasPrefix(clean, "/") || strings.Contains(clean, "..") {
return "", false
}
trimmed := strings.TrimPrefix(clean, "/")
top := trimmed
if i := strings.IndexByte(trimmed, '/'); i >= 0 {
top = trimmed[:i]
}
if _, allowed := allowedPrefixes[top]; !allowed {
return "", false
}
if trimmed == top { // bare "/library" with no object — not a media object
return "", false
}
return trimmed, true
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
key, ok := keyForPath(r.URL.Path)
if !ok {
http.NotFound(w, r)
return
}
obj, err := h.store.Get(r.Context(), key, r.Header.Get("Range"))
if err != nil {
switch {
case errors.Is(err, objectstore.ErrNotFound):
http.NotFound(w, r)
case errors.Is(err, objectstore.ErrRangeNotSatisfiable):
http.Error(w, "range not satisfiable", http.StatusRequestedRangeNotSatisfiable)
default:
h.logger.Error("assets get", "key", key, "error", err)
http.Error(w, "upstream unavailable", http.StatusBadGateway)
}
return
}
defer obj.Body.Close()
hdr := w.Header()
if obj.ContentType != "" {
hdr.Set("Content-Type", obj.ContentType)
}
hdr.Set("Cache-Control", cacheControl)
hdr.Set("Accept-Ranges", "bytes")
if obj.ETag != "" {
hdr.Set("ETag", obj.ETag)
}
if obj.LastModified != "" {
hdr.Set("Last-Modified", obj.LastModified)
}
if obj.ContentRange != "" {
hdr.Set("Content-Range", obj.ContentRange)
}
w.WriteHeader(obj.Status)
if r.Method == http.MethodHead {
return
}
if _, err := io.Copy(w, obj.Body); err != nil {
h.logger.Warn("assets stream interrupted", "key", key, "error", err)
}
}
Step 4: Run the test to verify it passes
Run: docker run --rm -v "$(git rev-parse --show-toplevel)/go-services":/src -w /src -v rmhgo-modcache:/go/pkg/mod golang:1.23-alpine go test ./internal/assets/...
Expected: PASS (6 tests).
Step 5: Commit
git add go-services/internal/assets
git commit -m "feat(go): assets HTTP handler — bucket streaming with Range + cache headers"
Task 3: cmd/assets entrypoint + Docker deploy registration¶
Files:
Create:
go-services/cmd/assets/main.goModify:
deploy/deploy-go.sh
Interfaces:
Consumes:
objectstore.New(Task 1),assets.NewHandler(Task 2),pkg/{config,httpx,log}.Produces: a
cmd/assetsbinary (built bygo-services/DockerfileviaSERVICE=assets) registered in the Docker deploy script.Step 1: Write the entrypoint
// go-services/cmd/assets/main.go
//
// Command assets serves /library, /music, /models, /sprites by streaming objects
// from the S3-compatible bucket. It replaces the Apache-off-disk static CDN for
// those prefixes. Exposes /health and /metrics on ASSETS_PORT.
package main
import (
"context"
"net/http"
"time"
"github.com/rmhstudios/rmh-go/internal/assets"
"github.com/rmhstudios/rmh-go/pkg/config"
"github.com/rmhstudios/rmh-go/pkg/httpx"
"github.com/rmhstudios/rmh-go/pkg/log"
"github.com/rmhstudios/rmh-go/pkg/objectstore"
"github.com/rmhstudios/rmh-go/pkg/telemetry"
)
func main() {
cfg, err := config.LoadCommon("assets")
logger := log.New("assets", cfg.LogLevel)
if err != nil {
logger.Fatal("config", "error", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
store, err := objectstore.New(ctx)
if err != nil {
logger.Fatal("objectstore", "error", err)
}
metrics := telemetry.New("assets")
mux := http.NewServeMux()
mux.HandleFunc("/health", httpx.Health("assets", nil))
mux.Handle("/metrics", metrics.Handler())
// The four media prefixes route here; everything else 404s.
mux.Handle("/library/", assets.NewHandler(store, logger))
mux.Handle("/music/", assets.NewHandler(store, logger))
mux.Handle("/models/", assets.NewHandler(store, logger))
mux.Handle("/sprites/", assets.NewHandler(store, logger))
addr := ":" + config.GetString("ASSETS_PORT", "7007")
srv := httpx.NewServer(addr, mux, logger)
if err := srv.Run(30 * time.Second); err != nil {
logger.Error("server", "error", err)
}
cancel()
}
Step 2: Verify the binary builds (Docker)
Run:
docker build -f go-services/Dockerfile --build-arg SERVICE=assets -t rmhstudios-go-assets:dev go-services
Expected: image builds successfully (the Dockerfile compiles ./cmd/assets into a static binary).
Step 3: Register the service in the Docker deploy script
In deploy/deploy-go.sh, add assets to the SERVICES array (around line 21):
SERVICES=(gateway gamehub rmhmusic rmhtube rmhbox recap doctrine-worker vibe-worker discord-bot assets)
The Dockerfile is generic over SERVICE, so no other build wiring is needed.
Step 4: Verify the deploy script references the service
Run: grep -n "assets" deploy/deploy-go.sh
Expected: assets appears in the SERVICES array.
Step 5: Commit
git add go-services/cmd/assets deploy/deploy-go.sh
git commit -m "feat(go): assets service entrypoint + docker deploy registration"
Task 4: Gateway routing to the assets service¶
Files:
Modify:
go-services/internal/gateway/proxy.goTest:
go-services/internal/gateway/gateway_test.go
Interfaces:
Consumes: the gateway
Config/LoadConfig/NewRouter(existing).Produces: gateway routes
/library/,/music/,/models/,/sprites/→cfg.AssetsUpstream.Step 1: Write the failing test
Add to go-services/internal/gateway/gateway_test.go inside (or alongside) TestPrefixRouterSelection’s cases — add these cases to its table:
{path: "/library/book.pdf", wantPrefix: "/library/"},
{path: "/music/song.mp3", wantPrefix: "/music/"},
{path: "/models/x.glb", wantPrefix: "/models/"},
{path: "/sprites/s.png", wantPrefix: "/sprites/"},
(If the test builds its Config literal, set AssetsUpstream: "http://assets:7007" there so NewRouter registers the prefixes.)
Step 2: Run the test to verify it fails
Run: docker run --rm -v "$(git rev-parse --show-toplevel)/go-services":/src -w /src -v rmhgo-modcache:/go/pkg/mod golang:1.23-alpine go test ./internal/gateway/...
Expected: FAIL — those paths currently resolve to the web catch-all (and AssetsUpstream field doesn’t exist).
Step 3: Implement the routing
In go-services/internal/gateway/proxy.go:
Add a default const near the other default*Upstream consts:
defaultAssetsUpstream = "http://assets:7007"
Add the field to Config:
type Config struct {
WebUpstream string
GamehubUpstream string
RmhboxUpstream string
RmhtubeUpstream string
RmhmusicUpstream string
AssetsUpstream string
StaticDir string
}
Read it in LoadConfig (add the line):
AssetsUpstream: config.GetString("ASSETS_UPSTREAM", defaultAssetsUpstream),
Add the four prefixes to the routes slice in NewRouter (after the WS entries):
{"/library/", cfg.AssetsUpstream},
{"/music/", cfg.AssetsUpstream},
{"/models/", cfg.AssetsUpstream},
{"/sprites/", cfg.AssetsUpstream},
Step 4: Run the test to verify it passes
Run: docker run --rm -v "$(git rev-parse --show-toplevel)/go-services":/src -w /src -v rmhgo-modcache:/go/pkg/mod golang:1.23-alpine go test ./internal/gateway/...
Expected: PASS (existing + 4 new cases).
Step 5: Commit
git add go-services/internal/gateway/proxy.go go-services/internal/gateway/gateway_test.go
git commit -m "feat(gateway): route /library /music /models /sprites to assets service"
Task 5: Deploy-time sync tooling¶
Files:
Create:
scripts/assets-sync.shModify:
Makefile(addassets-synctarget)
Interfaces:
Produces:
make assets-syncmirrorspublic/{library,music,models,sprites}→s3://$S3_BUCKET/{…}idempotently usingmc.Step 1: Write the sync script
# scripts/assets-sync.sh
#!/usr/bin/env bash
# Mirror the heavy static media dirs into the S3-compatible bucket. Idempotent:
# `mc mirror` only uploads new/changed files. Reads the S3_* env (same contract
# as the app + the Go assets service). Run AFTER the frontend build so generated
# library covers are included.
set -euo pipefail
: "${S3_ENDPOINT:?S3_ENDPOINT required}"
: "${S3_ACCESS_KEY_ID:?S3_ACCESS_KEY_ID required}"
: "${S3_SECRET_ACCESS_KEY:?S3_SECRET_ACCESS_KEY required}"
: "${S3_BUCKET:?S3_BUCKET required}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ALIAS="rmh-sync"
DIRS=(library music models sprites)
mc alias set "$ALIAS" "$S3_ENDPOINT" "$S3_ACCESS_KEY_ID" "$S3_SECRET_ACCESS_KEY" >/dev/null
mc mb --ignore-existing "$ALIAS/$S3_BUCKET" >/dev/null
for d in "${DIRS[@]}"; do
src="$ROOT/public/$d"
if [ ! -d "$src" ]; then
echo "[assets-sync] skip $d (no $src)"
continue
fi
echo "[assets-sync] mirroring public/$d -> $ALIAS/$S3_BUCKET/$d"
mc mirror --overwrite --remove "$src" "$ALIAS/$S3_BUCKET/$d"
done
echo "[assets-sync] done"
Make it executable:
chmod +x scripts/assets-sync.sh
Step 2: Add the Makefile target
In Makefile, add (near the other phony targets; keep the file’s existing .PHONY style):
assets-sync: ## Mirror public/{library,music,models,sprites} to the S3 bucket (reads S3_* env)
./scripts/assets-sync.sh
If the Makefile has a .PHONY: line, add assets-sync to it.
Step 3: Verify the script parses and the target is wired
Run:
bash -n scripts/assets-sync.sh && echo "syntax OK"
make -n assets-sync
Expected: syntax OK, and make -n prints ./scripts/assets-sync.sh.
Step 4: Commit
git add scripts/assets-sync.sh Makefile
git commit -m "feat(deploy): assets-sync — mirror static media to the bucket"
Task 6: Helm wiring for the assets service¶
Files:
Modify:
deploy/helm/rmhstudios-go/values.yamlModify:
deploy/helm/rmhstudios-go/templates/deployment.yaml
Interfaces:
Consumes: the chart’s generic per-service
deployment.yaml/service.yamltemplating over.Values.services, and the gateway env block.Produces: an
assetsDeployment + Service on port 7007, andASSETS_PORT+ASSETS_UPSTREAMenv wiring.Step 1: Add the service to
values.yaml
In deploy/helm/rmhstudios-go/values.yaml, under services:, add after the recap: block:
assets:
port: 7007
replicas: 2
path: /library/
Step 2: Wire
ASSETS_PORTandASSETS_UPSTREAMindeployment.yaml
In deploy/helm/rmhstudios-go/templates/deployment.yaml, in the per-service port env chain, add an else if branch for assets (place it before the gateway branch):
{{- else if eq $svc "assets" }}
- name: ASSETS_PORT
value: {{ $cfg.port | quote }}
Then, inside the gateway branch’s upstream block (after the rmhmusic with block, before WEB_UPSTREAM), add:
{{- with $root.Values.services.assets }}
- name: ASSETS_UPSTREAM
value: "http://{{ $fn }}-assets:{{ .port }}"
{{- end }}
Step 3: Render the chart and verify
Run:
helm template deploy/helm/rmhstudios-go | grep -E "rmhstudios-go-assets|ASSETS_PORT|ASSETS_UPSTREAM"
Expected: shows the assets Deployment + Service, ASSETS_PORT: "7007" on the assets pod, and ASSETS_UPSTREAM: http://rmhstudios-go-assets:7007 on the gateway pod.
(If helm is unavailable, run python3 -c "import yaml,sys; list(yaml.safe_load_all(open('deploy/helm/rmhstudios-go/values.yaml')))" && echo "values OK" and note that a real helm template must be run before deploy.)
Step 4: Commit
git add deploy/helm/rmhstudios-go/values.yaml deploy/helm/rmhstudios-go/templates/deployment.yaml
git commit -m "feat(deploy): helm wiring for the assets service (port 7007 + gateway upstream)"
Task 7: Cutover runbook + build-context exclusion¶
Files:
Modify:
docs/runbooks/go-backend-and-bazel.md(add an “Assets / CDN cutover” section)Modify:
.dockerignore(ensure the four dirs are excluded from the image once migrated)
Interfaces:
Consumes: everything above (service, sync, routing, helm).
Produces: the operator’s phased, reversible cutover procedure and the image-slimming step.
Step 1: Confirm/extend
.dockerignore
Check current exclusions:
grep -nE "library|music|models|sprites" .dockerignore || echo "NOT EXCLUDED"
If any of public/library, public/music, public/models, public/sprites are not excluded, add them:
public/library
public/music
public/models
public/sprites
(Do this only as the final cutover step in practice — but record the lines now so the image stops shipping the 720 MB once serving is fully migrated. Note in the runbook that these lines are activated at Phase 4.)
Step 2: Add the cutover runbook section
Append to docs/runbooks/go-backend-and-bazel.md:
## Assets / CDN cutover (library, music, models, sprites)
The `assets` Go service streams these four prefixes from the bucket. The
front door (Apache today, or the gateway) re-points one prefix at a time;
rollback is a one-line revert. The migration is independent of the Node→Go
gateway cutover.
1. **Populate the bucket:** `make assets-sync` (after a frontend build, so
generated library covers are included). Re-run on every deploy — it's
idempotent (`mc mirror` uploads only changed/new files).
2. **Deploy the assets service:** it ships in the Go chart
(`deploy/helm/rmhstudios-go`, service `assets`, port 7007) via
`./deploy/deploy-go.sh production` (single-node) or
`REGISTRY=… ./deploy/deploy-go.sh production` (multi-node).
3. **Smoke-test directly** (before routing public traffic):
`curl -s -o /dev/null -w "%{http_code}\n" http://<assets-host>:7007/models/<known>.glb`
and a Range request: `curl -s -D- -o /dev/null -H 'Range: bytes=0-1023' http://<assets-host>:7007/music/<known>.mp3` → expect `206` + `Content-Range`.
4. **Cut over the smallest prefix first (`/models`).**
- Gateway front door: already routed (Task 4) once the gateway is the edge.
- Apache front door: replace `Alias /models …` + `ProxyPass /models !` with
`ProxyPass /models http://<assets-host>:7007/models` (+ `ProxyPassReverse`),
then `apachectl configtest && systemctl reload apache2`.
Verify behind Cloudflare; watch error rate, latency, and range behavior.
5. **Roll the rest** one at a time: `music` → `sprites` → `library`.
6. **Phase 4 — slim the image:** activate the `.dockerignore` exclusions for the
four dirs, rebuild, and confirm the image is ~720 MB lighter.
7. **Rollback (any phase):** re-point the prefix back to the Apache `Alias` /
disk (or remove the gateway prefix) and reload.
Step 3: Commit
git add docs/runbooks/go-backend-and-bazel.md .dockerignore
git commit -m "docs(deploy): assets/CDN cutover runbook + image build-context exclusions"
Self-Review¶
Spec coverage:
pkg/objectstorerange-aware S3 client → Task 1. ✓internal/assetshandler (path→key, prefix validation, traversal, range, headers, errors) → Task 2. ✓cmd/assetsentrypoint + Docker deploy registration → Task 3. ✓Gateway routes the four prefixes → Task 4. ✓
Deploy-time
mc mirrorsync +make assets-sync→ Task 5. ✓Helm
assetsDeployment/Service +ASSETS_PORT/ASSETS_UPSTREAM→ Task 6. ✓Phased reversible cutover +
.dockerignoreimage slimming → Task 7. ✓Cache policy (
max-age=86400, stale-while-revalidate=604800,Accept-Ranges, ETag/Last-Modified passthrough) → Task 2 (cacheControlconst + header sets). ✓Error handling (404/416/502) → Task 2. ✓
Same bucket, distinct prefixes → Tasks 1–2 (key = path) + Task 5 (mirror to
<bucket>/<dir>). ✓
Acceptance criteria mapping: (1) Task 2/3 + e2e; (2) Task 2 tests; (3) Task 4; (4) Task 5; (5) Task 7; (6) Tasks 1/2/4 tests. ✓
Placeholder scan: No TBD/TODO; every code step has complete code. The optional Cloudflare path-purge from the spec is intentionally not a task (marked optional in the spec); it can be a follow-up.
Type consistency: Store.Get(ctx, key, rangeHeader) (*objectstore.Object, error) is defined in Task 2 and satisfied by *objectstore.S3.Get from Task 1 (same signature). objectstore.Object fields used in Task 2’s handler (Body, ContentType, ETag, LastModified, ContentRange, Status) all exist in Task 1’s struct. ErrNotFound/ErrRangeNotSatisfiable defined in Task 1, consumed in Task 2. AssetsUpstream/ASSETS_UPSTREAM/ASSETS_PORT/port 7007 consistent across Tasks 3, 4, 6.
Note on the build/test workflow: the project is staying on Docker (not Bazel) for now, and there is no local Go toolchain — every red/green run executes go test inside a golang:1.23-alpine container (see Global Constraints; the rmhgo-modcache volume avoids re-downloading modules each run), and images build via go-services/Dockerfile. No make gazelle/bazel steps, and no BUILD.bazel files are created for the new packages. If the project returns to Bazel later, a one-shot make gazelle regenerates them.