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,42 @@
|
||||
# Multi-stage build for the backend service. Mirrors platform/telegram/Dockerfile:
|
||||
# a golang-alpine builder yields a static binary shipped on distroless nonroot.
|
||||
#
|
||||
# The dictionary DAWGs are baked in from the scrabble-dictionary release artifact
|
||||
# (Stage 14) — the same set the Go CI downloads — and BACKEND_DICT_DIR points the
|
||||
# binary at them. The published solver module is fetched directly from Gitea
|
||||
# (GOPRIVATE), so the build stage needs git and network.
|
||||
#
|
||||
# Build from the repository root so go.work, go.work.sum, pkg/ and backend/ are all
|
||||
# in the Docker context:
|
||||
# docker build -f backend/Dockerfile -t scrabble-backend .
|
||||
|
||||
# --- dictionary artifact -----------------------------------------------------
|
||||
FROM alpine:3.20 AS dawg
|
||||
ARG DICT_VERSION=v1.0.0
|
||||
RUN apk add --no-cache curl tar
|
||||
RUN mkdir -p /dawg \
|
||||
&& curl -fsSL -o /tmp/dawg.tar.gz \
|
||||
"https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz" \
|
||||
&& tar xzf /tmp/dawg.tar.gz -C /dawg
|
||||
|
||||
# --- build -------------------------------------------------------------------
|
||||
FROM golang:1.26.3-alpine AS build
|
||||
WORKDIR /src
|
||||
# git: the published solver module is fetched from Gitea directly (GOPRIVATE).
|
||||
RUN apk add --no-cache git
|
||||
ENV GOPRIVATE=gitea.iliadenisov.ru/*
|
||||
|
||||
COPY go.work go.work.sum ./
|
||||
COPY pkg ./pkg
|
||||
COPY backend ./backend
|
||||
|
||||
# Reduce the workspace to what the backend needs: backend + pkg.
|
||||
RUN go work edit -dropuse=./gateway -dropuse=./platform/telegram
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/backend ./backend/cmd/backend
|
||||
|
||||
# --- runtime -----------------------------------------------------------------
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
COPY --from=build /out/backend /usr/local/bin/backend
|
||||
COPY --from=dawg /dawg /opt/dawg
|
||||
ENV BACKEND_DICT_DIR=/opt/dawg
|
||||
ENTRYPOINT ["/usr/local/bin/backend"]
|
||||
@@ -132,6 +132,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
hub := notify.NewHub(0)
|
||||
|
||||
accounts := account.NewStore(db)
|
||||
accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account"))
|
||||
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
|
||||
games.SetNotifier(hub)
|
||||
games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game"))
|
||||
|
||||
@@ -93,12 +93,14 @@ type Identity struct {
|
||||
|
||||
// Store is the Postgres-backed query surface for accounts and identities.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
db *sql.DB
|
||||
metrics *accountMetrics
|
||||
}
|
||||
|
||||
// NewStore constructs a Store wrapping db.
|
||||
// NewStore constructs a Store wrapping db. Metrics default to a no-op meter until
|
||||
// SetMetrics installs the real one during startup wiring.
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
return &Store{db: db, metrics: defaultAccountMetrics()}
|
||||
}
|
||||
|
||||
// ProvisionByIdentity returns the account bound to (kind, externalID), creating
|
||||
@@ -331,6 +333,11 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("account: create for identity (%s, %s): %w", kind, externalID, err)
|
||||
}
|
||||
// Count genuinely new durable accounts; robots are a fixed provisioned pool,
|
||||
// not users, so they are excluded.
|
||||
if kind != KindRobot {
|
||||
s.metrics.recordCreated(ctx, kind)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
@@ -355,6 +362,7 @@ func (s *Store) ProvisionGuest(ctx context.Context) (Account, error) {
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
return Account{}, fmt.Errorf("account: provision guest: %w", err)
|
||||
}
|
||||
s.metrics.recordCreated(ctx, kindGuest)
|
||||
return modelToAccount(row), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"go.opentelemetry.io/otel/metric/noop"
|
||||
)
|
||||
|
||||
// meterName scopes the account domain's OpenTelemetry instruments.
|
||||
const meterName = "scrabble/backend/account"
|
||||
|
||||
// kindGuest labels guest accounts in accounts_created_total. Guests carry no
|
||||
// identity, so they have no identity Kind; this is the metric label for them.
|
||||
const kindGuest = "guest"
|
||||
|
||||
// accountMetrics holds the account domain's operational instruments. It defaults
|
||||
// to no-ops (see defaultAccountMetrics); SetMetrics installs the real meter during
|
||||
// startup wiring.
|
||||
type accountMetrics struct {
|
||||
created metric.Int64Counter
|
||||
}
|
||||
|
||||
// defaultAccountMetrics returns instruments backed by a no-op meter.
|
||||
func defaultAccountMetrics() *accountMetrics {
|
||||
return newAccountMetrics(noop.NewMeterProvider().Meter(meterName))
|
||||
}
|
||||
|
||||
// newAccountMetrics builds the instruments on meter, falling back to a no-op
|
||||
// counter on the (rare) construction error.
|
||||
func newAccountMetrics(meter metric.Meter) *accountMetrics {
|
||||
c, err := meter.Int64Counter("accounts_created_total",
|
||||
metric.WithDescription("New accounts created, labelled by kind (telegram/email/guest); robots are not counted."))
|
||||
if err != nil {
|
||||
c, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter("accounts_created_total")
|
||||
}
|
||||
return &accountMetrics{created: c}
|
||||
}
|
||||
|
||||
// SetMetrics installs the meter the account store records to. It must be called
|
||||
// during startup wiring; the default is a no-op meter.
|
||||
func (s *Store) SetMetrics(meter metric.Meter) {
|
||||
if meter == nil {
|
||||
return
|
||||
}
|
||||
s.metrics = newAccountMetrics(meter)
|
||||
}
|
||||
|
||||
// recordCreated counts one newly created account of the given kind.
|
||||
func (m *accountMetrics) recordCreated(ctx context.Context, kind string) {
|
||||
m.created.Add(ctx, 1, metric.WithAttributes(attribute.String("kind", kind)))
|
||||
}
|
||||
Reference in New Issue
Block a user