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:
+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