Skip to content

Commit 19700d4

Browse files
committed
feat: add Guard end-to-end demo
1 parent 81bf2fd commit 19700d4

15 files changed

Lines changed: 384 additions & 9 deletions

File tree

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,20 @@ Replay fixed DLQ events after updating the contract:
126126
go run ./cmd/trackboard-guard replay --config ../../examples/guard/guard.local.yaml --limit 100
127127
```
128128

129+
## End-to-End Demo
130+
131+
Run Guard with a fake analytics destination and verify that a valid event is forwarded while an invalid event is blocked:
132+
133+
```powershell
134+
.\scripts\demo-guard-e2e.ps1
135+
```
136+
137+
Docker Compose can run the same runtime demo:
138+
139+
```bash
140+
docker compose up --build guard fake-destination
141+
```
142+
129143
## Backend Install
130144

131145
Use Python 3.12 and PostgreSQL. SQLite is not the serious backend path.
@@ -174,5 +188,6 @@ go test ./...
174188
- [GitHub Action](docs/github-action.md)
175189
- [Guard](docs/guard.md)
176190
- [Guard operations](docs/guard-operations.md)
191+
- [End-to-end demo](docs/demo.md)
177192
- [Product comparison](docs/comparison.md)
178193
- [API reference](docs/api-reference.md)

apps/guard/Dockerfile

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
FROM golang:1.26.4-alpine AS build
2+
3+
WORKDIR /src
4+
COPY go.mod go.sum ./
5+
RUN go mod download
6+
COPY . .
7+
RUN CGO_ENABLED=0 go build -o /out/trackboard-guard ./cmd/trackboard-guard
8+
9+
FROM alpine:3.21
10+
11+
RUN adduser -D -H trackboard
12+
USER trackboard
13+
COPY --from=build /out/trackboard-guard /usr/local/bin/trackboard-guard
14+
15+
ENTRYPOINT ["trackboard-guard"]

apps/guard/internal/config/config.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,18 @@ type Config struct {
1313
ContractFile string
1414
StoreFile string
1515
Destination string
16+
WorkerCount int
1617
Ready bool
1718
Mode string
1819
}
1920

2021
func Default() Config {
2122
return Config{
22-
HTTPAddr: ":8080",
23-
StoreFile: "guard.db",
24-
Ready: false,
25-
Mode: "block",
23+
HTTPAddr: ":8080",
24+
StoreFile: "guard.db",
25+
WorkerCount: 1,
26+
Ready: false,
27+
Mode: "block",
2628
}
2729
}
2830

@@ -55,6 +57,12 @@ func Load(path string) (Config, error) {
5557
cfg.StoreFile = value
5658
case "destination":
5759
cfg.Destination = value
60+
case "worker_count":
61+
workerCount, err := strconv.Atoi(value)
62+
if err != nil {
63+
return cfg, fmt.Errorf("invalid worker_count value: %w", err)
64+
}
65+
cfg.WorkerCount = workerCount
5866
case "ready":
5967
ready, err := strconv.ParseBool(value)
6068
if err != nil {

apps/guard/internal/config/config_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99
func TestLoadConfig(t *testing.T) {
1010
dir := t.TempDir()
1111
path := filepath.Join(dir, "guard.yaml")
12-
err := os.WriteFile(path, []byte("http_addr: \":9090\"\ncontract_file: \"contract.json\"\nstore_file: \"guard.db\"\ndestination: \"http://example.test/track\"\nready: true\n"), 0o600)
12+
err := os.WriteFile(path, []byte("http_addr: \":9090\"\ncontract_file: \"contract.json\"\nstore_file: \"guard.db\"\ndestination: \"http://example.test/track\"\nworker_count: 2\nready: true\n"), 0o600)
1313
if err != nil {
1414
t.Fatal(err)
1515
}
@@ -31,6 +31,9 @@ func TestLoadConfig(t *testing.T) {
3131
if cfg.Destination != "http://example.test/track" {
3232
t.Fatalf("Destination = %q", cfg.Destination)
3333
}
34+
if cfg.WorkerCount != 2 {
35+
t.Fatalf("WorkerCount = %d", cfg.WorkerCount)
36+
}
3437
if !cfg.Ready {
3538
t.Fatal("Ready = false")
3639
}

apps/guard/internal/server/server.go

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,25 @@ import (
66
"encoding/json"
77
"fmt"
88
"net/http"
9+
"strings"
910
"time"
1011

1112
"github.com/astoriel/trackboard/apps/guard/internal/config"
1213
"github.com/astoriel/trackboard/apps/guard/internal/contract"
14+
"github.com/astoriel/trackboard/apps/guard/internal/forwarder"
1315
"github.com/astoriel/trackboard/apps/guard/internal/health"
1416
"github.com/astoriel/trackboard/apps/guard/internal/ingest"
1517
"github.com/astoriel/trackboard/apps/guard/internal/metrics"
1618
"github.com/astoriel/trackboard/apps/guard/internal/policy"
19+
"github.com/astoriel/trackboard/apps/guard/internal/runtime"
1720
"github.com/astoriel/trackboard/apps/guard/internal/store"
1821
"github.com/astoriel/trackboard/apps/guard/internal/validator"
1922
)
2023

2124
type Server struct {
22-
httpServer *http.Server
23-
eventStore *store.Store
25+
httpServer *http.Server
26+
eventStore *store.Store
27+
workerCancel context.CancelFunc
2428
}
2529

2630
func New(cfg config.Config) *Server {
@@ -39,6 +43,16 @@ func New(cfg config.Config) *Server {
3943
eventStore = opened
4044
}
4145
}
46+
var workerCancel context.CancelFunc
47+
if eventStore != nil && isHTTPDestination(cfg.Destination) {
48+
workerCtx, cancel := context.WithCancel(context.Background())
49+
workerCancel = cancel
50+
go runtime.WorkerPool{
51+
Store: eventStore,
52+
Sender: forwarder.NewSegment(cfg.Destination),
53+
WorkerCount: cfg.WorkerCount,
54+
}.Run(workerCtx)
55+
}
4256
guardMetrics := metrics.New()
4357
mux.HandleFunc("GET /health/live", healthHandler.Live)
4458
mux.HandleFunc("GET /health/ready", healthHandler.Ready)
@@ -51,10 +65,15 @@ func New(cfg config.Config) *Server {
5165
Handler: mux,
5266
ReadHeaderTimeout: 5 * time.Second,
5367
},
54-
eventStore: eventStore,
68+
eventStore: eventStore,
69+
workerCancel: workerCancel,
5570
}
5671
}
5772

73+
func isHTTPDestination(destination string) bool {
74+
return strings.HasPrefix(destination, "http://") || strings.HasPrefix(destination, "https://")
75+
}
76+
5877
func trackHandler(cache *contract.Cache, eventStore *store.Store, destination string, mode policy.Mode, guardMetrics *metrics.Metrics) http.HandlerFunc {
5978
return func(w http.ResponseWriter, r *http.Request) {
6079
if cache == nil {
@@ -162,6 +181,9 @@ func (s *Server) ListenAndServe() error {
162181
}
163182

164183
func (s *Server) Shutdown(ctx context.Context) error {
184+
if s.workerCancel != nil {
185+
s.workerCancel()
186+
}
165187
err := s.httpServer.Shutdown(ctx)
166188
if s.eventStore != nil {
167189
if closeErr := s.eventStore.Close(); err == nil {

apps/guard/internal/server/server_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import (
77
"os"
88
"path/filepath"
99
"strings"
10+
"sync/atomic"
1011
"testing"
12+
"time"
1113

1214
"github.com/astoriel/trackboard/apps/guard/internal/config"
1315
"github.com/astoriel/trackboard/apps/guard/internal/store"
@@ -61,6 +63,40 @@ func TestTrackEndpointQueuesValidEventsInOutbox(t *testing.T) {
6163
assertDepths(t, storePath, 1, 0)
6264
}
6365

66+
func TestTrackEndpointForwardsValidEventsToDestination(t *testing.T) {
67+
contractPath := writeServerContract(t)
68+
storePath := dbPath(t)
69+
var received atomic.Int32
70+
destination := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
71+
if r.Method != http.MethodPost || r.URL.Path != "/track" {
72+
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
73+
}
74+
received.Add(1)
75+
w.WriteHeader(http.StatusAccepted)
76+
}))
77+
defer destination.Close()
78+
srv := New(config.Config{HTTPAddr: ":0", ContractFile: contractPath, StoreFile: storePath, Destination: destination.URL + "/track", Mode: "block", WorkerCount: 1})
79+
defer shutdownServer(t, srv)
80+
req := httptest.NewRequest(http.MethodPost, "/v1/track", strings.NewReader(`{
81+
"event":"signup_completed",
82+
"userId":"usr_123",
83+
"messageId":"msg_forwarded",
84+
"properties":{"user_id":"usr_123","signup_method":"google"}
85+
}`))
86+
87+
srv.httpServer.Handler.ServeHTTP(httptest.NewRecorder(), req)
88+
89+
deadline := time.Now().Add(2 * time.Second)
90+
for time.Now().Before(deadline) {
91+
if received.Load() == 1 {
92+
assertDepths(t, storePath, 0, 0)
93+
return
94+
}
95+
time.Sleep(25 * time.Millisecond)
96+
}
97+
t.Fatalf("destination received %d events", received.Load())
98+
}
99+
64100
func TestMetricsEndpointReportsAcceptedAndBlockedEvents(t *testing.T) {
65101
contractPath := writeServerContract(t)
66102
srv := New(config.Config{HTTPAddr: ":0", ContractFile: contractPath, StoreFile: dbPath(t), Destination: "segment", Mode: "block"})

docker-compose.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,5 +64,34 @@ services:
6464
depends_on:
6565
- api
6666

67+
fake-destination:
68+
image: node:22-alpine
69+
working_dir: /app
70+
command: ["node", "server.js"]
71+
volumes:
72+
- ./examples/fake-destination:/app:ro
73+
ports:
74+
- "9000:9000"
75+
healthcheck:
76+
test: ["CMD", "wget", "-qO-", "http://localhost:9000/health"]
77+
interval: 5s
78+
retries: 5
79+
80+
guard:
81+
build:
82+
context: ./apps/guard
83+
dockerfile: Dockerfile
84+
command: ["--config", "/config/guard.compose.yaml"]
85+
volumes:
86+
- ./examples/contracts:/contracts:ro
87+
- ./examples/guard/guard.compose.yaml:/config/guard.compose.yaml:ro
88+
- guarddata:/data
89+
ports:
90+
- "8080:8080"
91+
depends_on:
92+
fake-destination:
93+
condition: service_healthy
94+
6795
volumes:
6896
pgdata:
97+
guarddata:

docs/demo.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# End-to-End Demo
2+
3+
This demo proves the full runtime path:
4+
5+
1. Guard loads a published Trackboard contract.
6+
2. A valid Segment-compatible event is accepted into the outbox.
7+
3. The Guard worker forwards the valid event to a fake analytics destination.
8+
4. An invalid event is blocked and recorded in Guard metrics.
9+
10+
## Local Smoke Test
11+
12+
```powershell
13+
.\scripts\demo-guard-e2e.ps1
14+
```
15+
16+
Expected output:
17+
18+
```text
19+
Guard E2E demo passed: valid event forwarded, invalid event blocked
20+
```
21+
22+
The script starts:
23+
24+
- `examples/fake-destination/server.js` on `localhost:9000`
25+
- `apps/guard` on `localhost:8080`
26+
27+
It stops both processes when the check completes.
28+
29+
## Docker Compose
30+
31+
```bash
32+
docker compose up --build guard fake-destination
33+
```
34+
35+
Then send a valid event:
36+
37+
```bash
38+
curl -X POST http://localhost:8080/v1/track \
39+
-H "Content-Type: application/json" \
40+
-d @examples/events/signup.valid.json
41+
```
42+
43+
Check that the fake destination received it:
44+
45+
```bash
46+
curl http://localhost:9000/events
47+
```
48+
49+
Send an invalid event:
50+
51+
```bash
52+
curl -X POST http://localhost:8080/v1/track \
53+
-H "Content-Type: application/json" \
54+
-d @examples/events/signup.invalid.json
55+
```
56+
57+
Check Guard metrics:
58+
59+
```bash
60+
curl http://localhost:8080/metrics
61+
```

docs/guard-operations.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ ready: true
1515
mode: "block"
1616
```
1717
18+
When `destination` is an `http://` or `https://` URL, Guard starts a local forwarding worker. The worker leases due rows from the SQLite outbox and sends them to the configured destination.
19+
1820
## Metrics
1921

2022
`GET /metrics` returns plain counters for:

docs/guard.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Trackboard Guard is the runtime data plane. It validates Segment-compatible `/v1
1010
- Forward accepted events to an HTTP destination.
1111
- Store accepted events in a durable single-node SQLite outbox before forwarding.
1212
- Store rejected events in a single-node SQLite DLQ.
13-
- Retry failed forwarding attempts.
13+
- Run a local worker that leases outbox rows, forwards them, and retries failed delivery attempts.
1414
- Expose health and metrics endpoints.
1515
- Replay fixed DLQ events after the contract is updated.
1616

0 commit comments

Comments
 (0)