Skip to content

Commit 3039d63

Browse files
authored
Merge pull request #45 from dennisme/dennisme/embed-riverui
feat: riverui
2 parents 867fea8 + 992a2ee commit 3039d63

9 files changed

Lines changed: 248 additions & 11 deletions

File tree

cmd/grex/main.go

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import (
2020

2121
"net/http"
2222

23+
"riverqueue.com/riverui"
24+
2325
"github.com/dennisme/grex/internal/api"
2426
"github.com/dennisme/grex/internal/buildinfo"
2527
"github.com/dennisme/grex/internal/config"
@@ -31,6 +33,33 @@ import (
3133
"github.com/dennisme/grex/internal/ui"
3234
)
3335

36+
// mountRiverUI wraps River's own job/queue UI (riverqueue.com/riverui) at
37+
// /riverui, reusing the same River client the purge job already runs
38+
// (persistence.NewPurgeClient) — no second River client. No riverui-
39+
// specific auth added: it mounts on uiMux, the same mux server.New wraps
40+
// once with mTLS + SPIFFE role mapping when ui_tls.client_ca_file is
41+
// configured (see docs/admin/authentication.md), so this route inherits
42+
// that access control for free. That's a different axis from the
43+
// browser-login OIDC flow that's still unbuilt (issue #11) — this isn't
44+
// blocked on that, and doesn't need riverui's own
45+
// RIVER_BASIC_AUTH_USER/PASS mechanism, which would be a second,
46+
// inconsistent auth story to rip out later. Caller must still call
47+
// Start(ctx) on the returned handler before it's fully functional (caching
48+
// and background query support), same two-step shape as riverui's own
49+
// example.
50+
func mountRiverUI(uiMux *http.ServeMux, purgeClient *river.Client[pgx.Tx], logger *slog.Logger) (*riverui.Handler, error) {
51+
handler, err := riverui.NewHandler(&riverui.HandlerOpts{
52+
Logger: logger,
53+
Prefix: "/riverui",
54+
Endpoints: riverui.NewEndpoints(purgeClient, nil),
55+
})
56+
if err != nil {
57+
return nil, fmt.Errorf("river ui: %w", err)
58+
}
59+
uiMux.Handle("/riverui/", handler)
60+
return handler, nil
61+
}
62+
3463
const shutdownGrace = 10 * time.Second
3564

3665
// persistenceFlushInterval is how often dirty agents are saved to the
@@ -107,6 +136,7 @@ func run(args []string) error {
107136
var dirtyTracker *persistence.DirtyTracker
108137
var store persistence.StateStore
109138
var maxConcurrentWrites int
139+
var purgeClient *river.Client[pgx.Tx]
110140
if cfg.Database.Host != "" {
111141
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
112142
cfg.Database.Host, cfg.Database.Port, cfg.Database.User, cfg.Database.Password,
@@ -127,6 +157,16 @@ func run(args []string) error {
127157
// persistence.PoolCollector's doc comment on why this defaults to the
128158
// grex process's own CPU count, not the database's actual capacity).
129159
maxConcurrentWrites = int(pool.Config().MaxConns)
160+
161+
// Constructed here (not deferred to the goroutine-launching block
162+
// below) so mountRiverUI can mount its handler on uiMux before
163+
// srv.Start() begins serving it — registering a new route on a mux
164+
// already being served concurrently is a race. Only Start(ctx),
165+
// which needs the shutdown context, waits until after srv.Start().
166+
purgeClient, err = persistence.NewPurgeClient(pool, cfg.Fleet.SoftDeleteDuration, events, logger)
167+
if err != nil {
168+
return fmt.Errorf("purge client: %w", err)
169+
}
130170
}
131171

132172
registry := fleet.New(fleet.Config{
@@ -150,6 +190,14 @@ func run(args []string) error {
150190
}
151191
uiHandler.Mount(uiMux)
152192

193+
var riverUIHandler *riverui.Handler
194+
if dbPool != nil {
195+
riverUIHandler, err = mountRiverUI(uiMux, purgeClient, logger)
196+
if err != nil {
197+
return err
198+
}
199+
}
200+
153201
srv := server.New(cfg, logger,
154202
server.OpAMP{Handler: handler, ConnContext: connCtx},
155203
server.UI{Handler: uiMux},
@@ -162,21 +210,19 @@ func run(args []string) error {
162210
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
163211
defer stop()
164212
go registry.Run(ctx)
165-
var purgeClient *river.Client[pgx.Tx]
166213
if dbPool != nil {
167214
flusher := persistence.NewFlusher(registry, dirtyTracker, store, persistenceFlushInterval, logger, maxConcurrentWrites, events)
168215
go flusher.Run(ctx)
169216

170217
snapshotter := persistence.NewSessionSnapshotter(registry, store, sessionSnapshotInterval, logger, maxConcurrentWrites, events)
171218
go snapshotter.Run(ctx)
172219

173-
purgeClient, err = persistence.NewPurgeClient(dbPool, cfg.Fleet.SoftDeleteDuration, events, logger)
174-
if err != nil {
175-
return fmt.Errorf("purge client: %w", err)
176-
}
177220
if err := purgeClient.Start(ctx); err != nil {
178221
return fmt.Errorf("start purge client: %w", err)
179222
}
223+
if err := riverUIHandler.Start(ctx); err != nil {
224+
return fmt.Errorf("start river ui: %w", err)
225+
}
180226
defer func() {
181227
stopCtx, cancel := context.WithTimeout(context.Background(), shutdownGrace)
182228
defer cancel()

cmd/grex/main_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
11
package main
22

33
import (
4+
"context"
45
"io"
56
"log/slog"
67
"net"
78
"net/http"
9+
"net/http/httptest"
810
"os"
911
"path/filepath"
1012
"strings"
1113
"syscall"
1214
"testing"
1315
"time"
1416

17+
"github.com/jackc/pgx/v5/pgxpool"
18+
"github.com/riverqueue/river"
19+
"github.com/riverqueue/river/riverdriver/riverpgxv5"
20+
1521
"github.com/dennisme/grex/internal/config"
1622
"github.com/dennisme/grex/internal/server"
1723
"github.com/prometheus/client_golang/prometheus"
@@ -68,6 +74,41 @@ func TestNewLoggerLevelsAndFormats(t *testing.T) {
6874
}
6975
}
7076

77+
// TestMountRiverUI covers mountRiverUI in isolation: river.NewClient and
78+
// riverui.NewHandler are both lazy (verified empirically — neither
79+
// attempts a real connection at construction time, only Start/actual
80+
// queries do), so this needs no live Postgres, just a syntactically valid
81+
// (but unreachable) DSN.
82+
func TestMountRiverUI(t *testing.T) {
83+
pool, err := pgxpool.New(context.Background(), "host=127.0.0.1 port=1 user=x password=x dbname=x sslmode=disable")
84+
if err != nil {
85+
t.Fatalf("pgxpool.New: %v", err)
86+
}
87+
defer pool.Close()
88+
89+
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
90+
client, err := river.NewClient(riverpgxv5.New(pool), &river.Config{Logger: logger})
91+
if err != nil {
92+
t.Fatalf("river.NewClient: %v", err)
93+
}
94+
95+
uiMux := http.NewServeMux()
96+
handler, err := mountRiverUI(uiMux, client, logger)
97+
if err != nil {
98+
t.Fatalf("mountRiverUI: %v", err)
99+
}
100+
if handler == nil {
101+
t.Fatal("mountRiverUI returned a nil handler")
102+
}
103+
104+
req := httptest.NewRequest(http.MethodGet, "/riverui/", nil)
105+
rec := httptest.NewRecorder()
106+
uiMux.ServeHTTP(rec, req)
107+
if rec.Code == http.StatusNotFound {
108+
t.Errorf("GET /riverui/ = 404, want the route mounted")
109+
}
110+
}
111+
71112
func TestRunMissingConfig(t *testing.T) {
72113
err := run([]string{"-config", filepath.Join(t.TempDir(), "nope.yaml")})
73114
if err == nil {

compose.yaml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,35 @@ services:
6464
timeout: 3s
6565
retries: 12
6666

67+
grex-browser:
68+
# Plain-HTTP instance for casual local browsing (see
69+
# deploy/compose/grex-browser.yaml) — not part of the Envoy/gateway HA
70+
# topology, no agents connect to it, exists only so the UI/riverui are
71+
# reachable in a browser without installing an mTLS client cert.
72+
build:
73+
context: .
74+
dockerfile: Dockerfile
75+
command: ["-config", "/etc/grex/config.yaml"]
76+
ports:
77+
- "127.0.0.1:8082:8080"
78+
- "127.0.0.1:9095:9090"
79+
- "127.0.0.1:4322:4320"
80+
volumes:
81+
- ./deploy/compose/grex-browser.yaml:/etc/grex/config.yaml:ro
82+
- ./deploy/compose/certs:/certs:ro
83+
depends_on:
84+
gen-certs:
85+
condition: service_completed_successfully
86+
river-migrate:
87+
condition: service_completed_successfully
88+
migrate:
89+
condition: service_completed_successfully
90+
healthcheck:
91+
test: ["CMD", "wget", "-qO-", "--no-check-certificate", "https://127.0.0.1:9090/healthz"]
92+
interval: 5s
93+
timeout: 3s
94+
retries: 12
95+
6796
envoy:
6897
# Inner load-balancing tier: least-connections across grex/grex-2.
6998
# opamp-gateway dials this instead of a single grex instance directly.

deploy/compose/grex-browser.yaml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Dedicated, plain-HTTP grex instance for casual local browsing (Chrome,
2+
# etc.) without installing a short-lived mTLS client cert. Not part of the
3+
# Envoy/gateway HA topology (grex.yaml's grex/grex-2) — no agents connect
4+
# to this instance directly, it exists purely so the UI and /riverui are
5+
# reachable without cert friction. Shares the same Postgres, so it shows
6+
# the same real fleet via the cross-replica DB merge (see
7+
# docs/spec/design.md's Agent sharding scheme) even though it holds no
8+
# agent's live connection itself.
9+
listeners:
10+
opamp: ":4320"
11+
ui: ":8080"
12+
telemetry: ":9090"
13+
14+
opamp_tls:
15+
cert_file: /certs/server.pem
16+
key_file: /certs/server-key.pem
17+
client_ca_file: /certs/ca.pem
18+
19+
# No ui_tls: omitting it entirely means the UI listener gets neither TLS
20+
# (server.listenerTLSConfig returns nil without a cert_file) nor the
21+
# mTLS/role-check middleware (server.New's wrap() only applies it when
22+
# client_ca_file is set) — plain HTTP, fully open, by design, only on this
23+
# instance.
24+
25+
telemetry_tls:
26+
cert_file: /certs/server.pem
27+
key_file: /certs/server-key.pem
28+
client_ca_file: /certs/ca.pem
29+
30+
auth:
31+
default_role: none
32+
role_mapping:
33+
- match: exact
34+
spiffe_id: spiffe://grex-api.internal/user/alice
35+
role: viewer
36+
- match: exact
37+
spiffe_id: spiffe://grex-api.internal/user/admin
38+
role: admin
39+
- match: exact
40+
spiffe_id: spiffe://grex-api.internal/service/prometheus
41+
role: viewer
42+
43+
fleet:
44+
required_attributes:
45+
- deployment.environment
46+
- service.namespace
47+
48+
database:
49+
host: postgres
50+
port: 5432
51+
user: grex
52+
password: grex-dev-password
53+
dbname: grex
54+
sslmode: disable
55+
56+
log:
57+
level: debug
58+
format: json

deploy/compose/smoke.sh

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,25 @@ for host in 127.0.0.1:8080 127.0.0.1:8081; do
148148
done
149149
echo "cross-replica DB merge confirmed: both replicas report the full fleet via GET /api/agents"
150150

151+
# River UI is mounted only when database.host is set (deploy/compose/
152+
# grex.yaml sets it) — reuses the purge job's own River client. Same
153+
# mTLS-gated UI listener as everything else (server.go wraps the whole
154+
# uiMux once), so it needs a client cert same as any other UI route; no
155+
# separate riverui-specific auth mechanism was added.
156+
code=$(scripts/gxcurl -u alice -s -o /dev/null -w '%{http_code}' https://127.0.0.1:8080/riverui/)
157+
[ "$code" = "200" ] || fail "grex /riverui/: want 200, got $code"
158+
code=$(curl -sk -o /dev/null -w '%{http_code}' https://127.0.0.1:8080/riverui/)
159+
[ "$code" = "403" ] || fail "grex /riverui/ with no cert: want 403, got $code"
160+
161+
# grex-browser (deploy/compose/grex-browser.yaml) has no ui_tls at all:
162+
# plain HTTP, no client cert needed, not even -k for a self-signed cert.
163+
# Not part of the Envoy/gateway pool, but shares the same Postgres, so it
164+
# shows the same fleet via the cross-replica DB merge.
165+
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/api/status)
166+
[ "$code" = "200" ] || fail "grex-browser /api/status (plain http, no cert): want 200, got $code"
167+
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/riverui/)
168+
[ "$code" = "200" ] || fail "grex-browser /riverui/ (plain http, no cert): want 200, got $code"
169+
151170
# Prometheus scrapes both grex replicas as separate targets per job (2 each
152171
# for grex-server/grex-fleet), the three collectors' internal telemetry,
153172
# and Envoy's stats endpoint.

docs/developer/local-development.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ and `deploy/charts/smoke.sh --help`.
6262
[Scaling with gateways](../admin/scaling-with-gateways.md#load-balancing-across-grex-replicas).
6363
`grex-2` is reachable directly on host ports `8081`/`9092`/`4321` (same
6464
layout as `grex`'s `8080`/`9090`/`4320`) for `gxcurl`/debugging.
65+
- Want to poke around the UI or `/riverui` in a browser without installing
66+
a client cert? `grex-browser` (`deploy/compose/grex-browser.yaml`) has
67+
no `ui_tls` at all, plain HTTP: <http://127.0.0.1:8082>. Not part of the
68+
Envoy/gateway pool, but shares the same Postgres, so it shows the same
69+
fleet via the cross-replica DB merge.
6570
- If certs generated before this topology existed are still on disk
6671
(`deploy/compose/certs/`), `gen-certs.sh`'s per-file idempotency means the
6772
server cert won't pick up the new `grex-2`/`envoy` SANs automatically —

docs/reference/endpoints.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,21 @@ Optional TLS/mTLS per `opamp_tls.*`.
3838

3939
Details: [Read API](../developer/read-api.md).
4040

41+
### River UI
42+
43+
| Path | Description |
44+
|------|-------------|
45+
| `GET /riverui/…` | [River](https://riverqueue.com)'s own job/queue UI and API, mounted only when `database.host` is set |
46+
47+
Reuses the same River client the soft-delete purge job runs — today the
48+
only jobs visible are `purge_evicted_agents` runs, since grex's own
49+
mutation jobs (restart, config-push) aren't built yet. No riverui-specific
50+
auth was added; it's mounted on the same listener as the rest of the UI,
51+
so the mTLS + role mapping below applies to it identically.
52+
4153
**Auth:** optional mTLS with SPIFFE IDs, per `ui_tls.client_ca_file` and
42-
`auth.role_mapping`. Applies to every UI/API path when configured. See
43-
[Authentication](../admin/authentication.md).
54+
`auth.role_mapping`. Applies to every UI/API path when configured,
55+
including `/riverui/…`. See [Authentication](../admin/authentication.md).
4456

4557
## Telemetry listener (`listeners.telemetry`, default `:9090`)
4658

go.mod

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,32 @@ require (
1010
github.com/riverqueue/river v0.41.0
1111
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.41.0
1212
gopkg.in/yaml.v3 v3.0.1
13+
riverqueue.com/riverui v0.16.0
1314
)
1415

1516
require (
1617
github.com/beorn7/perks v1.0.1 // indirect
1718
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
1819
github.com/cespare/xxhash/v2 v2.3.0 // indirect
1920
github.com/davecgh/go-spew v1.1.1 // indirect
21+
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
22+
github.com/go-playground/locales v0.14.1 // indirect
23+
github.com/go-playground/universal-translator v0.18.1 // indirect
24+
github.com/go-playground/validator/v10 v10.30.1 // indirect
2025
github.com/gorilla/websocket v1.5.3 // indirect
26+
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 // indirect
2127
github.com/jackc/pgpassfile v1.0.0 // indirect
2228
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
2329
github.com/jackc/puddle/v2 v2.2.2 // indirect
2430
github.com/kylelemons/godebug v1.1.0 // indirect
31+
github.com/leodido/go-urn v1.4.0 // indirect
2532
github.com/michel-laterman/proxy-connect-dialer-go v0.1.0 // indirect
2633
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
2734
github.com/pmezard/go-difflib v1.0.0 // indirect
2835
github.com/prometheus/client_model v0.6.2 // indirect
2936
github.com/prometheus/common v0.70.1 // indirect
3037
github.com/prometheus/procfs v0.21.1 // indirect
38+
github.com/riverqueue/apiframe v0.0.0-20251229202423-2b52ce1c482e // indirect
3139
github.com/riverqueue/river/riverdriver v0.41.0 // indirect
3240
github.com/riverqueue/river/rivershared v0.41.0 // indirect
3341
github.com/riverqueue/river/rivertype v0.41.0 // indirect
@@ -37,6 +45,7 @@ require (
3745
github.com/tidwall/pretty v1.2.1 // indirect
3846
github.com/tidwall/sjson v1.2.5 // indirect
3947
go.uber.org/goleak v1.3.0 // indirect
48+
golang.org/x/crypto v0.46.0 // indirect
4049
golang.org/x/sync v0.22.0 // indirect
4150
golang.org/x/sys v0.47.0 // indirect
4251
golang.org/x/text v0.40.0 // indirect

0 commit comments

Comments
 (0)