Stage 16: deploy infra & test contour
- backend + gateway multi-stage distroless Dockerfiles; the gateway embeds and
serves the SPA at / and /telegram/ via go:embed (committed dist placeholder,
real build baked in by the image's node stage)
- deploy/docker-compose.yml: backend + gateway + Postgres + Telegram connector
(VPN sidecar) + OTel Collector + Prometheus (15d) + Tempo (72h) + Grafana,
fronted by a caddy owning a single /_gm Basic-Auth (admin console + Grafana
subpath); inter-service on a private network, only caddy on the edge network
- new metrics: backend accounts_created_total{kind} (robots excluded) and an
in-memory gateway active_users{window=24h,7d} gauge
- CI: single .gitea/workflows/ci.yaml (unit/integration/ui + a gated test-contour
deploy) on the new feature/* -> development -> master branch model; the old
go-unit/integration/ui-test workflows are folded in; the connector-scoped
compose is retired (superseded by deploy/)
- docs: ARCHITECTURE §11/§12/§13, root + gateway READMEs, CLAUDE.md branching,
PLAN.md (stage 16 done + refinements + Stage 17 forward-notes)
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
# Multi-stage build for the gateway service. A node stage builds the static UI
|
||||
# (Vite), the result is embedded into the Go binary (gateway/internal/webui/dist),
|
||||
# and the Go stage — mirroring platform/telegram/Dockerfile — yields a static
|
||||
# binary shipped on distroless nonroot. So the single binary serves the SPA at /
|
||||
# and /telegram/ (docs/ARCHITECTURE.md §13) with no separate static container.
|
||||
#
|
||||
# The production UI build vars are image build-args, baked into the bundle.
|
||||
# Build from the repository root so go.work, pkg/, gateway/ and ui/ are all in the
|
||||
# Docker context:
|
||||
# docker build -f gateway/Dockerfile \
|
||||
# --build-arg VITE_GATEWAY_URL=https://example \
|
||||
# -t scrabble-gateway .
|
||||
|
||||
# --- UI build ----------------------------------------------------------------
|
||||
FROM node:22-alpine AS ui
|
||||
WORKDIR /ui
|
||||
RUN corepack enable && corepack prepare pnpm@11.0.9 --activate
|
||||
|
||||
# Prod UI build vars (Vite reads VITE_-prefixed env at build; baked into the bundle).
|
||||
ARG VITE_TELEGRAM_BOT_ID=
|
||||
ARG VITE_TELEGRAM_LINK=
|
||||
ARG VITE_GATEWAY_URL=
|
||||
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
||||
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
|
||||
VITE_GATEWAY_URL=$VITE_GATEWAY_URL
|
||||
|
||||
# Install with the lockfile first (the workspace file carries pnpm's build-script
|
||||
# approval for esbuild), then build. Committed src/gen/ means no codegen here.
|
||||
COPY ui/package.json ui/pnpm-lock.yaml ui/pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY ui ./
|
||||
RUN pnpm build
|
||||
|
||||
# --- Go build ----------------------------------------------------------------
|
||||
FROM golang:1.26.3-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.work go.work.sum ./
|
||||
COPY pkg ./pkg
|
||||
COPY gateway ./gateway
|
||||
|
||||
# Replace the committed placeholder with the freshly built UI before compiling, so
|
||||
# go:embed bakes the real bundle into the binary.
|
||||
RUN rm -rf gateway/internal/webui/dist
|
||||
COPY --from=ui /ui/dist gateway/internal/webui/dist
|
||||
|
||||
# Reduce the workspace to what the gateway needs: gateway + pkg.
|
||||
RUN go work edit -dropuse=./backend -dropuse=./platform/telegram
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/gateway ./gateway/cmd/gateway
|
||||
|
||||
# --- runtime -----------------------------------------------------------------
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
COPY --from=build /out/gateway /usr/local/bin/gateway
|
||||
ENTRYPOINT ["/usr/local/bin/gateway"]
|
||||
+9
-4
@@ -5,9 +5,13 @@ terminates the client's **Connect-RPC + FlatBuffers** traffic over HTTP/2
|
||||
cleartext (`h2c`), authenticates the originating credential, mints/resolves a
|
||||
thin opaque session, rate-limits, injects `X-User-ID` when forwarding to the
|
||||
backend over REST/JSON, and bridges the backend's gRPC push stream to each
|
||||
client's in-app live channel. It also serves the backend's admin console at `/_gm`
|
||||
on its public listener behind HTTP Basic-Auth. See
|
||||
[`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) §2, §3, §10, §12.
|
||||
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
|
||||
in by the gateway image's node stage) and serves the one SPA at `/` (web) and
|
||||
`/telegram/` (the Mini App) — the single-origin model. It can also serve the
|
||||
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
|
||||
in the deployed contour the front caddy owns `/_gm` (see
|
||||
[`../deploy`](../deploy)). See
|
||||
[`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) §2, §3, §10, §12, §13.
|
||||
|
||||
## Package layout
|
||||
|
||||
@@ -22,8 +26,9 @@ internal/ratelimit/ # token-bucket limiter (golang.org/x/time/rate)
|
||||
internal/connector/ # gRPC client to the Telegram connector (initData validate, out-of-app push) + routing
|
||||
internal/push/ # live-event fan-out hub (per-user client streams)
|
||||
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
|
||||
internal/connectsrv/ # the Connect Gateway service over h2c
|
||||
internal/connectsrv/ # the Connect Gateway service over h2c (+ the in-memory active_users gauge)
|
||||
internal/admin/ # Basic-Auth reverse proxy mounting the backend admin console at /_gm (verbatim)
|
||||
internal/webui/ # embedded SPA build (go:embed dist) served at / and /telegram/
|
||||
```
|
||||
|
||||
The FlatBuffers payloads and the backend push proto are the shared wire
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package connectsrv
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// activeUsers tracks distinct authenticated accounts by last-action time, backing
|
||||
// the in-memory active_users gauge. It is single-process by design (the gateway is
|
||||
// single-instance in the MVP, docs/ARCHITECTURE.md §10): the distinct count is
|
||||
// correct for one process, resets on restart, and is a live operational gauge, not
|
||||
// a billing figure. Memory is bounded by the number of distinct accounts active
|
||||
// within the longest window; stale entries are pruned on observation.
|
||||
type activeUsers struct {
|
||||
mu sync.Mutex
|
||||
lastSeen map[string]time.Time
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// newActiveUsers returns an empty tracker using the wall clock.
|
||||
func newActiveUsers() *activeUsers {
|
||||
return &activeUsers{lastSeen: make(map[string]time.Time), now: time.Now}
|
||||
}
|
||||
|
||||
// seen records that account uid performed an authenticated action now.
|
||||
func (a *activeUsers) seen(uid string) {
|
||||
if uid == "" {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.lastSeen[uid] = a.now()
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// counts returns, for each window, the number of distinct accounts last seen
|
||||
// within it, pruning entries older than the longest window in the same pass.
|
||||
func (a *activeUsers) counts(windows []time.Duration) []int {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
now := a.now()
|
||||
var longest time.Duration
|
||||
for _, w := range windows {
|
||||
if w > longest {
|
||||
longest = w
|
||||
}
|
||||
}
|
||||
|
||||
res := make([]int, len(windows))
|
||||
for uid, ts := range a.lastSeen {
|
||||
age := now.Sub(ts)
|
||||
if age > longest {
|
||||
delete(a.lastSeen, uid)
|
||||
continue
|
||||
}
|
||||
for i, w := range windows {
|
||||
if age <= w {
|
||||
res[i]++
|
||||
}
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package connectsrv
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestActiveUsersCountsAndPrune(t *testing.T) {
|
||||
a := newActiveUsers()
|
||||
base := time.Date(2026, 6, 5, 12, 0, 0, 0, time.UTC)
|
||||
cur := base
|
||||
a.now = func() time.Time { return cur }
|
||||
|
||||
a.seen("u1") // at base
|
||||
cur = base.Add(2 * time.Hour)
|
||||
a.seen("u2") // base+2h
|
||||
cur = base.Add(50 * time.Hour)
|
||||
a.seen("u3") // base+50h
|
||||
|
||||
windows := []time.Duration{24 * time.Hour, 7 * 24 * time.Hour}
|
||||
|
||||
// now = base+50h: u3 within 24h; all three within 7d.
|
||||
got := a.counts(windows)
|
||||
if got[0] != 1 || got[1] != 3 {
|
||||
t.Fatalf("counts at +50h = %v, want [1 3]", got)
|
||||
}
|
||||
|
||||
// now = base+169h: u1 (age 169h) prunes past the 7d window; u2/u3 remain in 7d.
|
||||
cur = base.Add(169 * time.Hour)
|
||||
got = a.counts(windows)
|
||||
if got[0] != 0 || got[1] != 2 {
|
||||
t.Fatalf("counts at +169h = %v, want [0 2]", got)
|
||||
}
|
||||
if _, ok := a.lastSeen["u1"]; ok {
|
||||
t.Fatalf("u1 should have been pruned from the tracker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveUsersIgnoresEmpty(t *testing.T) {
|
||||
a := newActiveUsers()
|
||||
a.seen("")
|
||||
if got := a.counts([]time.Duration{time.Hour}); got[0] != 0 {
|
||||
t.Fatalf("empty uid recorded: got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -12,14 +12,26 @@ import (
|
||||
// meterName scopes the gateway edge's OpenTelemetry instruments.
|
||||
const meterName = "scrabble/gateway/edge"
|
||||
|
||||
// activeUserWindows are the rolling windows the active_users gauge reports.
|
||||
var activeUserWindows = []struct {
|
||||
label string
|
||||
dur time.Duration
|
||||
}{
|
||||
{label: "24h", dur: 24 * time.Hour},
|
||||
{label: "7d", dur: 7 * 24 * time.Hour},
|
||||
}
|
||||
|
||||
// serverMetrics holds the edge's operational instruments. It defaults to no-ops;
|
||||
// NewServer installs the real meter when one is supplied in Deps.
|
||||
type serverMetrics struct {
|
||||
edge metric.Float64Histogram
|
||||
edge metric.Float64Histogram
|
||||
active *activeUsers
|
||||
}
|
||||
|
||||
// newServerMetrics builds the instruments on meter (nil selects a no-op meter),
|
||||
// falling back to a no-op histogram on the (rare) construction error.
|
||||
// falling back to a no-op histogram on the (rare) construction error. The
|
||||
// active_users gauge is registered as an observable callback over the in-memory
|
||||
// tracker.
|
||||
func newServerMetrics(meter metric.Meter) *serverMetrics {
|
||||
if meter == nil {
|
||||
meter = noop.NewMeterProvider().Meter(meterName)
|
||||
@@ -30,7 +42,24 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
|
||||
if err != nil {
|
||||
h, _ = noop.NewMeterProvider().Meter(meterName).Float64Histogram("edge_request_duration")
|
||||
}
|
||||
return &serverMetrics{edge: h}
|
||||
m := &serverMetrics{edge: h, active: newActiveUsers()}
|
||||
|
||||
gauge, err := meter.Int64ObservableGauge("active_users",
|
||||
metric.WithDescription("Distinct accounts that performed an authenticated action within the window (in-memory, single gateway instance)."))
|
||||
if err == nil {
|
||||
windows := make([]time.Duration, len(activeUserWindows))
|
||||
for i, w := range activeUserWindows {
|
||||
windows[i] = w.dur
|
||||
}
|
||||
_, _ = meter.RegisterCallback(func(_ context.Context, o metric.Observer) error {
|
||||
counts := m.active.counts(windows)
|
||||
for i, w := range activeUserWindows {
|
||||
o.ObserveInt64(gauge, int64(counts[i]), metric.WithAttributes(attribute.String("window", w.label)))
|
||||
}
|
||||
return nil
|
||||
}, gauge)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// recordEdge records the duration of one Execute call labelled by message type and
|
||||
@@ -41,3 +70,8 @@ func (m *serverMetrics) recordEdge(ctx context.Context, msgType, result string,
|
||||
attribute.String("result", result),
|
||||
))
|
||||
}
|
||||
|
||||
// recordActive marks account uid active now, feeding the active_users gauge.
|
||||
func (m *serverMetrics) recordActive(uid string) {
|
||||
m.active.seen(uid)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"scrabble/gateway/internal/ratelimit"
|
||||
"scrabble/gateway/internal/session"
|
||||
"scrabble/gateway/internal/transcode"
|
||||
"scrabble/gateway/internal/webui"
|
||||
edgev1 "scrabble/gateway/proto/edge/v1"
|
||||
"scrabble/gateway/proto/edge/v1/edgev1connect"
|
||||
)
|
||||
@@ -89,9 +90,21 @@ func (s *Server) HTTPHandler() http.Handler {
|
||||
if s.adminProxy != nil {
|
||||
// The admin console (backend /_gm) is served on the public listener behind
|
||||
// the proxy's Basic-Auth, mounted below the h2c wrap so the Connect edge keeps
|
||||
// working over h2c (docs/ARCHITECTURE.md §12).
|
||||
// working over h2c (docs/ARCHITECTURE.md §12). In the deployed contour the
|
||||
// front caddy owns the /_gm Basic-Auth and Grafana routing; this mount serves
|
||||
// a non-caddy (local) setup.
|
||||
mux.Handle("/_gm/", s.adminProxy)
|
||||
} else {
|
||||
// With the console disabled here, keep /_gm a 404 so the SPA catch-all below
|
||||
// does not serve the app shell at the operator path.
|
||||
mux.Handle("/_gm/", http.NotFoundHandler())
|
||||
}
|
||||
// The embedded single-page UI is served at the site root and, for the Telegram
|
||||
// Mini App, under /telegram/ — the single-origin model (docs/ARCHITECTURE.md
|
||||
// §13). Both mounts sit below the h2c wrap so the Connect edge (a more specific
|
||||
// prefix) keeps priority; "/" is the catch-all SPA fallback for the hash router.
|
||||
mux.Handle("/telegram/", webui.Handler("/telegram/"))
|
||||
mux.Handle("/", webui.Handler(""))
|
||||
return h2c.NewHandler(mux, &http2.Server{})
|
||||
}
|
||||
|
||||
@@ -118,6 +131,9 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
||||
result = "unauthenticated"
|
||||
return nil, err
|
||||
}
|
||||
// A valid session proving an authenticated request is an "action" for the
|
||||
// active_users gauge, counted before the rate-limit/domain outcome.
|
||||
s.metrics.recordActive(uid)
|
||||
if !s.limiter.Allow("user:"+uid, s.userPolicy) {
|
||||
result = "rate_limited"
|
||||
return nil, connect.NewError(connect.CodeResourceExhausted, errRateLimited)
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
# Placeholder so the embedded dist/assets directory exists in a plain build.
|
||||
# The production gateway image replaces dist/ with the real Vite build.
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Scrabble</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>
|
||||
UI build placeholder. The production gateway image embeds the real Vite
|
||||
build (see gateway/Dockerfile); seeing this page means the binary was
|
||||
built without a UI build.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,71 @@
|
||||
// Package webui serves the embedded single-page UI build over the public edge.
|
||||
//
|
||||
// The committed dist/ holds only a placeholder index.html so the gateway module
|
||||
// compiles with a plain `go build` (and in CI) without a UI build. The production
|
||||
// gateway image replaces dist/ with the real Vite build before compiling (see
|
||||
// gateway/Dockerfile), so the binary ships the UI inside it. Because Vite is built
|
||||
// with a relative asset base, one build serves under any path: Handler is mounted
|
||||
// both at "/" (web) and at "/telegram/" (the Telegram Mini App), matching the
|
||||
// single-origin model in docs/ARCHITECTURE.md §13.
|
||||
package webui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var dist embed.FS
|
||||
|
||||
// distFS returns the embedded build rooted at dist/. The directory is embedded at
|
||||
// compile time, so its absence is a build error rather than a runtime condition.
|
||||
func distFS() fs.FS {
|
||||
sub, err := fs.Sub(dist, "dist")
|
||||
if err != nil {
|
||||
panic("webui: embedded dist/ missing: " + err.Error())
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
// Handler serves the embedded SPA. An existing file is served directly (with the
|
||||
// standard content-type and caching headers); every other path falls back to
|
||||
// index.html so the client-side hash router can take over a deep link. When
|
||||
// stripPrefix is non-empty it is removed from the request path before lookup, so
|
||||
// the same build serves under a sub-path (e.g. "/telegram/").
|
||||
func Handler(stripPrefix string) http.Handler {
|
||||
content := distFS()
|
||||
files := http.FileServer(http.FS(content))
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
|
||||
if name == "" {
|
||||
serveIndex(w, content)
|
||||
return
|
||||
}
|
||||
if info, err := fs.Stat(content, name); err != nil || info.IsDir() {
|
||||
// Unknown path or a directory: serve the SPA shell, never a listing.
|
||||
serveIndex(w, content)
|
||||
return
|
||||
}
|
||||
files.ServeHTTP(w, r)
|
||||
})
|
||||
if p := strings.TrimSuffix(stripPrefix, "/"); p != "" {
|
||||
return http.StripPrefix(p, h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// serveIndex writes the SPA shell with a 200 status, so a client-routed deep link
|
||||
// still loads the app rather than a 404.
|
||||
func serveIndex(w http.ResponseWriter, content fs.FS) {
|
||||
data, err := fs.ReadFile(content, "index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "ui not built", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// get drives the handler with a GET for the given path and returns the response.
|
||||
func get(t *testing.T, h http.Handler, target string) *http.Response {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
return rec.Result()
|
||||
}
|
||||
|
||||
func TestHandlerServesIndexAndFallsBack(t *testing.T) {
|
||||
h := Handler("")
|
||||
|
||||
// The embedded placeholder index is served at the root.
|
||||
if resp := get(t, h, "/"); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET / status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
// An existing (non-index) file is served directly by the file server.
|
||||
if resp := get(t, h, "/assets/.gitkeep"); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET /assets/.gitkeep status = %d, want 200 (served file)", resp.StatusCode)
|
||||
}
|
||||
|
||||
// An unknown deep link falls back to the SPA shell (200, not 404) so the
|
||||
// client-side hash router can take over.
|
||||
resp := get(t, h, "/game/abc/deep")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET /game/abc/deep status = %d, want 200 (SPA fallback)", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), "<html") {
|
||||
t.Fatalf("fallback body is not the index HTML: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerStripsPrefix(t *testing.T) {
|
||||
h := Handler("/telegram/")
|
||||
|
||||
for _, target := range []string{"/telegram/", "/telegram/assets/.gitkeep", "/telegram/lobby/x"} {
|
||||
if resp := get(t, h, target); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET %s status = %d, want 200", target, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user