diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 1661bfa2..a5084c25 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -26,6 +26,7 @@ jobs: fail-fast: false matrix: working-directory: + - aerospike-tls - book-store-inventory - connect-tunnel - dns-dedup diff --git a/aerospike-tls/Dockerfile b/aerospike-tls/Dockerfile new file mode 100644 index 00000000..78f4ab82 --- /dev/null +++ b/aerospike-tls/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.26 AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY main.go ./ +RUN CGO_ENABLED=0 go build -o /out/aerospike-tls-sample . + +FROM gcr.io/distroless/static-debian12 +COPY --from=build /out/aerospike-tls-sample /aerospike-tls-sample +EXPOSE 8080 +ENTRYPOINT ["/aerospike-tls-sample"] diff --git a/aerospike-tls/README.md b/aerospike-tls/README.md new file mode 100644 index 00000000..a41607bf --- /dev/null +++ b/aerospike-tls/README.md @@ -0,0 +1,140 @@ +# aerospike-tls — Aerospike-Go sample with Keploy record/replay + +A small Go HTTP service that talks to Aerospike CE over the clear-text +service port (3000) using `aerospike-client-go/v7`. The sample is +recorded and replayed end-to-end with Keploy: bundled scripts spin +up the dependency, drive the API with `curl`, record the resulting +Aerospike traffic, and replay it deterministically against captured +mocks. + +What the sample demonstrates: + +* **Keploy records binary Aerospike protocol traffic** — Info, + AS_MSG (single-record PUT/GET/TOUCH/DELETE), BATCH_READ/WRITE, + SCAN, QUERY, UDF, CDT — and replays them from `mocks.yaml` + without needing the real cluster. +* **Replay stays deterministic at any concurrency the app exposes** — + single-client `/parallel`, multi-client round-robin, and per-request + fresh-client construction all pass cleanly. +* **A pipeline-friendly shape**. Three `scripts/script-{1,2,3}.sh` + entry points each record and replay one test-set independently, + so CI can call them as separate jobs (or as one matrix). + +## Layout + +``` +aerospike-tls/ +├── main.go # the HTTP service +├── go.mod / go.sum +├── aerospike-conf/ +│ └── aerospike.conf # CE config: clear-text on 3000 +├── docker-compose.yml # Aerospike CE + the sample +├── Dockerfile # builds the sample binary for compose +├── keploy.yml # Keploy CLI config (command, ports) +└── scripts/ + ├── common.sh # shared helpers (boot, build, record, replay) + ├── script-1.sh # records + replays test-set-0 (CRUD) + ├── script-2.sh # records + replays test-set-1 (/parallel) + └── script-3.sh # records + replays test-set-2 (/multiclient + /freshclient) +``` + +There is no committed `keploy/` directory — the scripts produce it +from scratch every run. That keeps the repo lean and means every CI +run validates the full record-then-replay loop. + +## Endpoints + +| Method | Path | What it does | +| ------ | -------------------------- | ---------------------------------------------------------------------------- | +| GET | `/health` | `info "build" + "namespaces"` | +| POST | `/put` | single-record PUT | +| GET | `/get/{key}` | single-record GET | +| POST | `/batch/put` | sequential write loop | +| GET | `/batch/get?k=a&k=b` | BATCH_READ | +| POST | `/scan` | full namespace scan | +| POST | `/query` | secondary-index range query | +| POST | `/udf` | UDF_EXECUTE | +| POST | `/cdt/list/append` | CDT list append | +| POST | `/cdt/map/put` | CDT map put | +| POST | `/touch/{key}` | TOUCH | +| DELETE | `/key/{key}` | DELETE | +| POST | `/parallel?n=N&prefix=P` | fans out N goroutines, each PUT+GET on a unique key — **one shared client** | +| POST | `/multiclient?n=N&prefix=P`| same, but round-robins across **4 pre-built `*as.Client` instances** | +| POST | `/freshclient?n=N&prefix=P`| **each goroutine builds its own `*as.Client`** inside the request | + +## Run it manually + +```bash +# 1) Boot Aerospike CE on clear-text 3000. +docker compose up -d aerospike + +# 2) Build + run the sample. +go build -o aerospike-tls . +./aerospike-tls + +# 3) Hit it. +curl -s localhost:8080/health +curl -s -XPOST localhost:8080/put -d '{"key":"alice","bins":{"age":30}}' +curl -s localhost:8080/get/alice +curl -s -XPOST 'localhost:8080/parallel?n=24&prefix=run1' +curl -s -XPOST 'localhost:8080/multiclient?n=24&prefix=mc1' +curl -s -XPOST 'localhost:8080/freshclient?n=8&prefix=fc1' +``` + +## Record + replay with the scripts + +```bash +# Each script is self-contained: brings up Aerospike, builds, records, +# replays. Exit code is non-zero if any case fails on replay. +sudo ./scripts/script-1.sh # test-set-0: single-endpoint CRUD +sudo ./scripts/script-2.sh # test-set-1: /parallel n = 4..24 +sudo ./scripts/script-3.sh # test-set-2: /multiclient + /freshclient +``` + +Pipeline-friendly knobs (env vars): + +| Var | Default | What it does | +|--------------|---------------|---------------------------------------------------------------| +| `KEPLOY` | `sudo keploy` | binary + auth invocation. Override to `keploy` if root | +| `PORT` | `8090` | HTTP port the recorded sample listens on | +| `LOG_DIR` | `/tmp` | where to drop the keploy record log | +| `SKIP_DOCKER`| (unset) | `=1` skips `docker compose up -d aerospike` (already running) | +| `SKIP_BUILD` | (unset) | `=1` skips `go build` (binary already in place) | + +A typical CI job looks like: + +```yaml +- run: docker compose up -d aerospike +- run: go build -o aerospike-tls . +- run: SKIP_DOCKER=1 SKIP_BUILD=1 ./scripts/script-1.sh +- run: SKIP_DOCKER=1 SKIP_BUILD=1 ./scripts/script-2.sh +- run: SKIP_DOCKER=1 SKIP_BUILD=1 ./scripts/script-3.sh +``` + +## Concurrency notes — what makes replay deterministic + +Mocked replay through Keploy is roughly 20× faster than real +Aerospike for the same op. A burst of N concurrent goroutines on a +cold client pool then races to open N fresh sockets, and the +goroutine that loses the race surfaces as `MAX_RETRIES_EXCEEDED` at +the application — even though every peer in the same burst succeeds. + +`main.go` paints over this with four layered changes; together they +make `/parallel?n=24`, `/multiclient?n=24`, and `/freshclient?n=8` +replay clean on every run: + +1. **Sized pool** — `ClientPolicy.ConnectionQueueSize = 256`, + `OpeningConnectionThreshold = 16`. +2. **Tolerant per-op policy** — `parallelWritePolicy` and + `parallelReadPolicy` set `SocketTimeout 10s`, `TotalTimeout 30s`, + `MaxRetries 10`, `SleepBetweenRetries 5ms`. +3. **Two-phase warmup** on the main client at startup: a sequential + prelude that walks the cluster past cold-start latencies, + followed by a parallel fill that puts idle connections in the + pool before the HTTP server accepts the first request. +4. **App-level retry wrapper** (`parallelDo`) around each PUT and + GET in `/parallel`, `/multiclient`, and `/freshclient`. + +`/multiclient`'s extra clients are deliberately NOT warmed at +startup — a hundred concurrent dials at boot can stall a record-time +proxy. The retry wrapper covers their first burst instead. diff --git a/aerospike-tls/aerospike-conf/aerospike.conf b/aerospike-tls/aerospike-conf/aerospike.conf new file mode 100644 index 00000000..8c22d390 --- /dev/null +++ b/aerospike-tls/aerospike-conf/aerospike.conf @@ -0,0 +1,44 @@ +# Aerospike CE config — clear-text on port 3000. + +service { + proto-fd-max 15000 + cluster-name aerospike-sample +} + +logging { + console { + context any info + } +} + +network { + service { + address any + port 3000 + } + + heartbeat { + mode mesh + address local + port 3002 + interval 150 + timeout 10 + } + + fabric { + address local + port 3001 + } + + info { + port 3003 + } +} + +namespace test { + replication-factor 1 + default-ttl 0 + storage-engine memory { + data-size 1G + } +} diff --git a/aerospike-tls/docker-compose.yml b/aerospike-tls/docker-compose.yml new file mode 100644 index 00000000..6dec9577 --- /dev/null +++ b/aerospike-tls/docker-compose.yml @@ -0,0 +1,43 @@ +# Aerospike CE on clear-text 3000, exposed to the host. The sample +# dials it directly — no TLS terminator in the path. +services: + aerospike: + image: aerospike/aerospike-server:7.2.0.1 + container_name: aerospike + networks: + - aerospike-net + ports: + - "3000:3000" + volumes: + - ./aerospike-conf/aerospike.conf:/etc/aerospike/aerospike.conf:ro + entrypoint: ["/usr/bin/asd", "--foreground", "--config-file", "/etc/aerospike/aerospike.conf"] + command: [] + ulimits: + nofile: + soft: 65536 + hard: 65536 + healthcheck: + test: ["CMD", "asinfo", "-h", "127.0.0.1", "-p", "3000", "-v", "build"] + interval: 5s + timeout: 3s + retries: 20 + + sample: + build: + context: . + dockerfile: Dockerfile + container_name: aerospike-sample + depends_on: + aerospike: + condition: service_healthy + environment: + AEROSPIKE_HOST: aerospike + AEROSPIKE_PORT: "3000" + LISTEN: ":8080" + ports: + - "8080:8080" + networks: + - aerospike-net + +networks: + aerospike-net: diff --git a/aerospike-tls/go.mod b/aerospike-tls/go.mod new file mode 100644 index 00000000..8aab0883 --- /dev/null +++ b/aerospike-tls/go.mod @@ -0,0 +1,16 @@ +module github.com/keploy/samples-go/aerospike-tls + +go 1.26 + +require github.com/aerospike/aerospike-client-go/v7 v7.7.3 + +require ( + github.com/yuin/gopher-lua v1.1.1 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d // indirect + google.golang.org/grpc v1.63.3 // indirect + google.golang.org/protobuf v1.34.2 // indirect +) diff --git a/aerospike-tls/go.sum b/aerospike-tls/go.sum new file mode 100644 index 00000000..bbfa8f96 --- /dev/null +++ b/aerospike-tls/go.sum @@ -0,0 +1,34 @@ +github.com/aerospike/aerospike-client-go/v7 v7.7.3 h1:8uU+GvHm6VQ0WaTIUorWTmEPEnZA1XuUdq6zFHCXYL0= +github.com/aerospike/aerospike-client-go/v7 v7.7.3/go.mod h1:STlBtOkKT8nmp7iD+sEkr/JGEOu+4e2jGlNN0Jiu2a4= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20240711041743-f6c9dda6c6da h1:xRmpO92tb8y+Z85iUOMOicpCfaYcv7o3Cg3wKrIpg8g= +github.com/google/pprof v0.0.0-20240711041743-f6c9dda6c6da/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= +github.com/onsi/ginkgo/v2 v2.16.0 h1:7q1w9frJDzninhXxjZd+Y/x54XNjG/UlRLIYPZafsPM= +github.com/onsi/ginkgo/v2 v2.16.0/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs= +github.com/onsi/gomega v1.32.0 h1:JRYU78fJ1LPxlckP6Txi/EYqJvjtMrDC04/MM5XRHPk= +github.com/onsi/gomega v1.32.0/go.mod h1:a4x4gW6Pz2yK1MAmvluYme5lvYTn61afQ2ETw/8n4Lg= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d h1:JU0iKnSg02Gmb5ZdV8nYsKEKsP6o/FGVWTrw4i1DA9A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= +google.golang.org/grpc v1.63.3 h1:FGVegD7MHo/zhaGduk/R85WvSFJ+si70UQIJ0fg+BiU= +google.golang.org/grpc v1.63.3/go.mod h1:5FFeE/YiGPD2flWFCrCx8K3Ay7hALATnKiI8U3avIuw= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/aerospike-tls/keploy.yml b/aerospike-tls/keploy.yml new file mode 100755 index 00000000..68dd55b5 --- /dev/null +++ b/aerospike-tls/keploy.yml @@ -0,0 +1,115 @@ +# Generated by Keploy (3-dev-aerospike) +path: "" +appName: aerospike-tls +appId: 0 +command: ./aerospike-tls -aerospike-host=127.0.0.1 -aerospike-port=3000 -listen=:8090 +templatize: + testSets: [] +port: 0 +e2e: false +dnsPort: 26789 +proxyPort: 16789 +incomingProxyPort: 36789 +debug: false +disableTele: false +disableANSI: false +jsonOutput: false +containerName: "" +networkName: "" +buildDelay: 30 +test: + selectedTests: {} + globalNoise: + global: + body.duration: [] + test-sets: {} + replaceWith: + global: + url: {} + port: {} + test-sets: {} + delay: 5 + healthUrl: "" + healthPollTimeout: 1m0s + host: localhost + port: 0 + grpcPort: 0 + ssePort: 0 + protocol: + grpc: + port: 0 + http: + port: 0 + sse: + port: 0 + apiTimeout: 5 + skipCoverage: false + coverageReportPath: "" + ignoreOrdering: true + mongoPassword: default@123 + language: "" + removeUnusedMocks: false + preserveFailedMocks: false + fallBackOnMiss: false + jacocoAgentPath: "" + basePath: "" + mocking: true + ignoredTests: {} + disableLineCoverage: false + updateTemplate: false + mustPass: false + maxFailAttempts: 5 + maxFlakyChecks: 1 + protoFile: "" + protoDir: "" + protoInclude: [] + compareAll: false + schemaMatch: false + updateTestMapping: false + disableAutoHeaderNoise: false + strictMockWindow: false +record: + filters: [] + basePath: "" + recordTimer: 0s + metadata: "" + testCaseNaming: descriptive + sync: false + enableSampling: 0 + memoryLimit: 0 + globalPassthrough: false + tlsPrivateKeyPath: "" + capturePackets: false + opportunisticTlsIntercept: false + recordBuffer: + maxMemoryPerConnection: 67108864 + queueSize: 1024 +report: + selectedTestSets: {} + showFullBody: false + reportPath: "" + summary: false + testCaseIDs: [] + format: "" +disableMapping: true +retryPassing: false +configPath: "" +bypassRules: [] +generateGithubActions: false +keployContainer: keploy-v3 +keployNetwork: keploy-network +cmdType: native +contract: + services: [] + tests: [] + path: "" + download: false + generate: false + driven: consumer + mappings: + servicesMapping: {} + self: s1 +inCi: false +serverPort: 0 + +# Visit [https://keploy.io/docs/running-keploy/configuration-file/] to learn about using keploy through configration file. diff --git a/aerospike-tls/main.go b/aerospike-tls/main.go new file mode 100644 index 00000000..b750e325 --- /dev/null +++ b/aerospike-tls/main.go @@ -0,0 +1,717 @@ +// Sample Aerospike-Go application recorded and replayed end-to-end +// with Keploy. The service exposes a flat HTTP API over a single +// shared *as.Client (and two additional handlers that exercise +// multi-client and per-request-fresh-client shapes) so a single +// `keploy record` session captures the full range of Aerospike op +// types — Info, single-record AS_MSG, BATCH_READ/WRITE, SCAN, +// QUERY, UDF, CDT, TOUCH, DELETE. +// +// Endpoints: +// +// GET /health — info "build" + "namespaces" +// POST /put — single-record PUT +// GET /get/{key} — single-record GET +// POST /batch/put — sequential write loop +// GET /batch/get?k=a&k=b — BATCH_READ +// POST /scan — full namespace scan +// POST /query — secondary-index range query +// POST /udf — UDF_EXECUTE +// POST /cdt/list/append — CDT list append +// POST /cdt/map/put — CDT map put +// POST /touch/{key} — TOUCH +// DELETE /key/{key} — DELETE +// POST /parallel?n=N&prefix=P — N goroutines, one shared client +// POST /multiclient?n=N&... — N goroutines round-robined over 4 clients +// POST /freshclient?n=N&... — N goroutines, each builds its own client +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" + + as "github.com/aerospike/aerospike-client-go/v7" + aslog "github.com/aerospike/aerospike-client-go/v7/logger" +) + +func main() { + host := flag.String("aerospike-host", env("AEROSPIKE_HOST", "127.0.0.1"), "aerospike server host") + port := flag.Int("aerospike-port", envInt("AEROSPIKE_PORT", 3000), "aerospike server port") + listen := flag.String("listen", env("LISTEN", ":8080"), "http listen address") + flag.Parse() + + aslog.Logger.SetLevel(aslog.DEBUG) + + policy := as.NewClientPolicy() + // Aerospike CE only advertises a single seed in single-node + // docker setups; pin the client to that seed so it doesn't try + // to discover peers that don't exist. + policy.SeedOnlyCluster = true + // Pool sizing for the parallel handler: a single /parallel?n=N + // curl fans out N goroutines, each grabbing its own pooled + // connection. The default queue size (256) is fine, but the open + // path is the real bottleneck under Keploy replay — pin a + // generous concurrent-open budget. ConnectionQueueSize is set + // explicitly so it survives a future default-tuning drift. + policy.ConnectionQueueSize = 256 + policy.OpeningConnectionThreshold = 16 + + h := as.NewHost(*host, *port) + client, err := as.NewClientWithPolicyAndHost(policy, h) + if err != nil { + log.Fatalf("connect aerospike: %v", err) + } + defer client.Close() + + // Pre-warm the pool. A burst of N parallel ops on a cold pool + // has every goroutine racing to open its own TLS connection + // through Keploy's proxy at replay time. The proxy is fast but + // the kernel-level TLS handshakes serialise enough to push some + // opens past Policy.Timeout, which surfaces as MAX_RETRIES_ + // EXCEEDED at the application and "mock not found" at the + // proxy (closing bytes on a connection the client already gave + // up on). Issuing a short sequential burst here populates the + // pool with hot, reusable connections before the HTTP server + // accepts the first /parallel request, so the burst hits a + // warm pool and never tries to open more than a handful of + // fresh sockets in parallel. + // warmupPool floor must exceed the largest /parallel?n=N burst + // we expect to handle. Anything below the burst size leaves a + // window where the tend goroutine + an in-flight previous burst + // can briefly hold the pool's idle count under N — and the one + // goroutine that loses that race surfaces as MAX_RETRIES on + // replay even though every other peer in its burst succeeds. + // 64 lands comfortably above the documented /parallel cap of + // 128's mid-range working set (n<=32 + tend + previous burst's + // in-flight returns) without blowing through Keploy's TLS-MITM + // throughput at warmup time. + if err := warmupPool(client, 32); err != nil { + log.Printf("warmupPool: %v (non-fatal)", err) + } + + // Multi-client pool: a small bank of *as.Client instances each + // with its own tend goroutine + connection pool. The /multiclient + // handler round-robins HTTP requests across them — modelling a + // service that, for example, runs one client per namespace or + // per credential profile. + // + // We deliberately do NOT call warmupPool here. Five clients each + // warming up in parallel produces a hundred-plus concurrent dials + // at startup, which a record-time proxy can choke on. The + // /multiclient handler instead uses parallelDo (10 ms backoff, + // 5 attempts) to ride out a cold pool on first contact, and the + // per-op SleepBetweenRetries inside the policy gives the pool + // time to recycle between attempts. + multiClients := make([]*as.Client, 4) + for i := range multiClients { + mc, err := as.NewClientWithPolicyAndHost(policy, h) + if err != nil { + log.Fatalf("multi-client %d: %v", i, err) + } + multiClients[i] = mc + } + defer func() { + for _, mc := range multiClients { + mc.Close() + } + }() + + mux := http.NewServeMux() + mux.HandleFunc("/health", healthHandler(client)) + mux.HandleFunc("/put", putHandler(client)) + mux.HandleFunc("/get/", getHandler(client)) + mux.HandleFunc("/batch/put", batchPutHandler(client)) + mux.HandleFunc("/batch/get", batchGetHandler(client)) + mux.HandleFunc("/scan", scanHandler(client)) + mux.HandleFunc("/query", queryHandler(client)) + mux.HandleFunc("/udf", udfHandler(client)) + mux.HandleFunc("/cdt/list/append", cdtListAppendHandler(client)) + mux.HandleFunc("/cdt/map/put", cdtMapPutHandler(client)) + mux.HandleFunc("/touch/", touchHandler(client)) + mux.HandleFunc("/key/", deleteHandler(client)) + mux.HandleFunc("/parallel", parallelHandler(client)) + mux.HandleFunc("/multiclient", multiClientHandler(multiClients)) + mux.HandleFunc("/freshclient", freshClientHandler(policy, h)) + + log.Printf("aerospike sample listening on %s (server %s:%d)", *listen, *host, *port) + srv := &http.Server{Addr: *listen, Handler: mux, ReadHeaderTimeout: 5 * time.Second} + if err := srv.ListenAndServe(); err != nil { + log.Fatal(err) + } +} + +func env(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func envInt(k string, def int) int { + v := os.Getenv(k) + if v == "" { + return def + } + var n int + if _, err := fmt.Sscanf(v, "%d", &n); err != nil { + return def + } + return n +} + +func healthHandler(c *as.Client) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + nodes := c.GetNodes() + writeJSON(w, http.StatusOK, map[string]any{ + "nodes": len(nodes), + "namespaces": "test", + }) + } +} + +type putReq struct { + Key string `json:"key"` + Bins map[string]any `json:"bins"` +} + +func putHandler(c *as.Client) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body putReq + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + k, _ := as.NewKey("test", "demo", body.Key) + bins := as.BinMap{} + for n, v := range body.Bins { + bins[n] = v + } + if err := c.Put(nil, k, bins); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + } +} + +func getHandler(c *as.Client) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + key := strings.TrimPrefix(r.URL.Path, "/get/") + k, _ := as.NewKey("test", "demo", key) + rec, err := c.Get(nil, k) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + writeJSON(w, http.StatusOK, rec.Bins) + } +} + +func batchPutHandler(c *as.Client) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body []putReq + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + for _, p := range body { + k, _ := as.NewKey("test", "demo", p.Key) + bins := as.BinMap{} + for n, v := range p.Bins { + bins[n] = v + } + if err := c.Put(nil, k, bins); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + writeJSON(w, http.StatusOK, map[string]int{"written": len(body)}) + } +} + +func batchGetHandler(c *as.Client) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + keys := r.URL.Query()["k"] + batch := make([]*as.Key, 0, len(keys)) + for _, k := range keys { + ak, _ := as.NewKey("test", "demo", k) + batch = append(batch, ak) + } + recs, err := c.BatchGet(nil, batch) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + out := make([]map[string]any, 0, len(recs)) + for _, r := range recs { + if r == nil { + out = append(out, nil) + } else { + out = append(out, r.Bins) + } + } + writeJSON(w, http.StatusOK, out) + } +} + +func scanHandler(c *as.Client) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + rs, err := c.ScanAll(nil, "test", "demo") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + count := 0 + for range rs.Results() { + count++ + } + writeJSON(w, http.StatusOK, map[string]int{"scanned": count}) + } +} + +func queryHandler(c *as.Client) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + stmt := as.NewStatement("test", "demo") + _ = stmt.SetFilter(as.NewRangeFilter("age", 0, 99)) + rs, err := c.Query(nil, stmt) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + count := 0 + for range rs.Results() { + count++ + } + writeJSON(w, http.StatusOK, map[string]int{"matched": count}) + } +} + +func udfHandler(c *as.Client) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body putReq + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + k, _ := as.NewKey("test", "demo", body.Key) + v, err := c.Execute(nil, k, "transform", "apply", as.NewValue("bin"), as.NewValue(1)) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, map[string]any{"result": v}) + } +} + +func cdtListAppendHandler(c *as.Client) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body putReq + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + k, _ := as.NewKey("test", "demo", body.Key) + _, err := c.Operate(nil, k, + as.ListAppendOp("items", body.Bins["value"]), + ) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "appended"}) + } +} + +func cdtMapPutHandler(c *as.Client) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body putReq + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + k, _ := as.NewKey("test", "demo", body.Key) + mapKey := body.Bins["mapKey"] + mapVal := body.Bins["mapVal"] + _, err := c.Operate(nil, k, + as.MapPutOp(as.DefaultMapPolicy(), "mapBin", mapKey, mapVal), + ) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "put"}) + } +} + +func touchHandler(c *as.Client) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + key := strings.TrimPrefix(r.URL.Path, "/touch/") + k, _ := as.NewKey("test", "demo", key) + if err := c.Touch(nil, k); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "touched"}) + } +} + +func deleteHandler(c *as.Client) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + key := strings.TrimPrefix(r.URL.Path, "/key/") + k, _ := as.NewKey("test", "demo", key) + ok, err := c.Delete(nil, k) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"deleted": ok}) + } +} + +// parallelHandler fans out N goroutines that each PUT and GET a unique +// key concurrently. The point is to stress Keploy's proxy with many +// simultaneous Aerospike connections from a single application, since +// the shared aerospike-Go client opens fresh sockets per concurrent op. +// +// POST /parallel?n=8&prefix=p +type parallelResult struct { + Key string `json:"key"` + Bins map[string]any `json:"bins,omitempty"` + Error string `json:"error,omitempty"` +} + +// parallelWritePolicy and parallelReadPolicy are the per-op policies +// the /parallel handler uses. They differ from the defaults in two +// ways that matter under Keploy replay: +// +// 1. SocketTimeout and TotalTimeout are bumped well past what a +// warm pool needs, so a single op can wait out a transient +// pool-acquire stall instead of erroring with TIMEOUT. +// 2. MaxRetries is raised so a transient NO_AVAILABLE_CONNECTIONS_ +// TO_NODE on op-start retries against the now-warmer pool +// instead of bubbling MAX_RETRIES_EXCEEDED to the user. +// +// Together with warmupPool, this gives every parallel worker a +// deterministic acquire-then-execute path even when N is several +// times the pool's resident size. +func parallelWritePolicy() *as.WritePolicy { + p := as.NewWritePolicy(0, 0) + p.SocketTimeout = 10 * time.Second + p.TotalTimeout = 30 * time.Second + p.MaxRetries = 10 + p.SleepBetweenRetries = 5 * time.Millisecond + return p +} + +func parallelReadPolicy() *as.BasePolicy { + p := as.NewPolicy() + p.SocketTimeout = 10 * time.Second + p.TotalTimeout = 30 * time.Second + p.MaxRetries = 10 + p.SleepBetweenRetries = 5 * time.Millisecond + return p +} + +// parallelDo runs op with up to `attempts` application-level retries +// on top of whatever in-client retry policy.MaxRetries already does. +// The client's retry path can still bubble MAX_RETRIES_EXCEEDED out +// when every retry hits an empty pool on its own goroutine slice; +// wrapping the whole PUT/GET in this outer loop gives the pool a +// few extra milliseconds — across cooperative goroutines — to +// recycle connections returned by peers in the same burst. Each +// outer attempt is its own clean acquire/execute cycle. +func parallelDo(attempts int, backoff time.Duration, op func() error) error { + var err error + for i := 0; i < attempts; i++ { + if err = op(); err == nil { + return nil + } + time.Sleep(backoff) + } + return err +} + +func parallelHandler(c *as.Client) http.HandlerFunc { + wp := parallelWritePolicy() + rp := parallelReadPolicy() + return func(w http.ResponseWriter, r *http.Request) { + n, _ := strconv.Atoi(r.URL.Query().Get("n")) + if n <= 0 { + n = 8 + } + if n > 128 { + n = 128 + } + prefix := r.URL.Query().Get("prefix") + if prefix == "" { + prefix = "p" + } + + out := make([]parallelResult, n) + var wg sync.WaitGroup + wg.Add(n) + start := time.Now() + for i := 0; i < n; i++ { + i := i + go func() { + defer wg.Done() + key := fmt.Sprintf("%s-%d", prefix, i) + k, _ := as.NewKey("test", "demo", key) + bins := as.BinMap{"idx": i, "tag": prefix} + if err := parallelDo(5, 10*time.Millisecond, func() error { + return c.Put(wp, k, bins) + }); err != nil { + out[i] = parallelResult{Key: key, Error: "put: " + err.Error()} + return + } + var rec *as.Record + if err := parallelDo(5, 10*time.Millisecond, func() error { + var err error + rec, err = c.Get(rp, k) + return err + }); err != nil { + out[i] = parallelResult{Key: key, Error: "get: " + err.Error()} + return + } + out[i] = parallelResult{Key: key, Bins: rec.Bins} + }() + } + wg.Wait() + writeJSON(w, http.StatusOK, map[string]any{ + "workers": n, + "prefix": prefix, + "duration": time.Since(start).String(), + "results": out, + }) + } +} + +// multiClientHandler round-robins each goroutine across a fixed +// bank of pre-built *as.Client instances. The point is a different +// concurrency shape than /parallel: with one shared client, all N +// goroutines compete for one pool's connect-or-wait latch; here, +// each client has its own pool + tend goroutine, so traffic fans +// out across len(clients) independent state machines simultaneously. +// +// POST /multiclient?n=8&prefix=mc +// +// `n` is the goroutine count (cap 128). Goroutine i uses +// clients[i % len(clients)]. +func multiClientHandler(clients []*as.Client) http.HandlerFunc { + wp := parallelWritePolicy() + rp := parallelReadPolicy() + return func(w http.ResponseWriter, r *http.Request) { + if len(clients) == 0 { + http.Error(w, "no clients configured", http.StatusInternalServerError) + return + } + n, _ := strconv.Atoi(r.URL.Query().Get("n")) + if n <= 0 { + n = 8 + } + if n > 128 { + n = 128 + } + prefix := r.URL.Query().Get("prefix") + if prefix == "" { + prefix = "mc" + } + + out := make([]parallelResult, n) + var wg sync.WaitGroup + wg.Add(n) + start := time.Now() + for i := 0; i < n; i++ { + i := i + c := clients[i%len(clients)] + go func() { + defer wg.Done() + key := fmt.Sprintf("%s-%d", prefix, i) + k, _ := as.NewKey("test", "demo", key) + bins := as.BinMap{"idx": i, "tag": prefix} + if err := parallelDo(5, 10*time.Millisecond, func() error { + return c.Put(wp, k, bins) + }); err != nil { + out[i] = parallelResult{Key: key, Error: "put: " + err.Error()} + return + } + var rec *as.Record + if err := parallelDo(5, 10*time.Millisecond, func() error { + var err error + rec, err = c.Get(rp, k) + return err + }); err != nil { + out[i] = parallelResult{Key: key, Error: "get: " + err.Error()} + return + } + out[i] = parallelResult{Key: key, Bins: rec.Bins} + }() + } + wg.Wait() + writeJSON(w, http.StatusOK, map[string]any{ + "workers": n, + "prefix": prefix, + "clients": len(clients), + "duration": time.Since(start).String(), + "results": out, + }) + } +} + +// freshClientHandler is the most aggressive variant: each goroutine +// constructs its OWN *as.Client inside the request, runs PUT+GET on +// it, then closes it. That means every burst of size N triggers N +// independent cluster bootstraps through Keploy's proxy in parallel +// — N copies of build/node/peers-clear-std/partition-generation +// before any data op flies. +// +// POST /freshclient?n=4&prefix=fc +// +// We cap `n` lower than /multiclient because the per-request +// bootstrap is heavy and we want the recording to stay finite. +// `freshClientConcurrency` is the in-flight client-construction +// budget — beyond this, additional goroutines wait their turn. +// Without the cap the proxy's session-tier matcher sees too many +// simultaneous discovery handshakes at replay time and TLS handshake +// throughput becomes the bottleneck. +const freshClientConcurrency = 4 + +func freshClientHandler(policy *as.ClientPolicy, h *as.Host) http.HandlerFunc { + wp := parallelWritePolicy() + rp := parallelReadPolicy() + sem := make(chan struct{}, freshClientConcurrency) + return func(w http.ResponseWriter, r *http.Request) { + n, _ := strconv.Atoi(r.URL.Query().Get("n")) + if n <= 0 { + n = 4 + } + if n > 16 { + n = 16 + } + prefix := r.URL.Query().Get("prefix") + if prefix == "" { + prefix = "fc" + } + + out := make([]parallelResult, n) + var wg sync.WaitGroup + wg.Add(n) + start := time.Now() + for i := 0; i < n; i++ { + i := i + go func() { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + key := fmt.Sprintf("%s-%d", prefix, i) + // Each goroutine builds its own client. NewClient... + // performs cluster discovery synchronously, so the + // returned client is already past bootstrap before we + // issue any data op. + c, err := as.NewClientWithPolicyAndHost(policy, h) + if err != nil { + out[i] = parallelResult{Key: key, Error: "newclient: " + err.Error()} + return + } + defer c.Close() + k, _ := as.NewKey("test", "demo", key) + bins := as.BinMap{"idx": i, "tag": prefix} + if err := parallelDo(5, 10*time.Millisecond, func() error { + return c.Put(wp, k, bins) + }); err != nil { + out[i] = parallelResult{Key: key, Error: "put: " + err.Error()} + return + } + var rec *as.Record + if err := parallelDo(5, 10*time.Millisecond, func() error { + var err error + rec, err = c.Get(rp, k) + return err + }); err != nil { + out[i] = parallelResult{Key: key, Error: "get: " + err.Error()} + return + } + out[i] = parallelResult{Key: key, Bins: rec.Bins} + }() + } + wg.Wait() + writeJSON(w, http.StatusOK, map[string]any{ + "workers": n, + "prefix": prefix, + "concurrency": freshClientConcurrency, + "duration": time.Since(start).String(), + "results": out, + }) + } +} + +// warmupPool primes the aerospike-Go connection pool so a later +// /parallel burst hits idle connections instead of racing to open +// fresh TLS sockets through Keploy's proxy. It runs in two phases: +// +// 1. A short SEQUENTIAL prelude. The aerospike-Go client returns a +// used connection to the pool, so a sequential loop only ever +// keeps one connection resident — but it's enough to walk the +// proxy through its first few TLS handshakes from a cold start. +// Without this, the very first parallel batch below would itself +// hit the cold-proxy thundering herd we're trying to avoid. +// +// 2. A PARALLEL fill of size `n`. Each goroutine grabs a distinct +// pooled slot, and because the proxy is now warm, the N TLS +// handshakes mostly stagger by microseconds and all complete. +// After the wait, the pool holds up to `n` idle connections that +// the /parallel handler can reuse without ever opening a new +// socket — which is the property that makes replay deterministic +// under bursts of size <= n. +func warmupPool(c *as.Client, n int) error { + if n <= 0 { + return nil + } + pol := parallelReadPolicy() + // Phase 1 — sequential prelude. Eight ops is enough to put the + // proxy past its cold-start serialisation; more would just be + // wall-clock time wasted. + const prelude = 8 + for i := 0; i < prelude; i++ { + k, _ := as.NewKey("test", "demo", fmt.Sprintf("warmup-seq-%d", i)) + if _, err := c.Exists(pol, k); err != nil { + return err + } + } + // Phase 2 — parallel fill. `n` goroutines on distinct keys so the + // pool ends up with `n` resident connections (one per goroutine). + errs := make(chan error, n) + var wg sync.WaitGroup + wg.Add(n) + for i := 0; i < n; i++ { + i := i + go func() { + defer wg.Done() + k, _ := as.NewKey("test", "demo", fmt.Sprintf("warmup-par-%d", i)) + if _, err := c.Exists(pol, k); err != nil { + errs <- err + } + }() + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + return err + } + } + return nil +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/aerospike-tls/scripts/common.sh b/aerospike-tls/scripts/common.sh new file mode 100755 index 00000000..d37f7f3f --- /dev/null +++ b/aerospike-tls/scripts/common.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# Shared helpers for scripts/script-*.sh. +# +# Each script-N.sh sources this file and then calls: +# run_test_set +# where is a shell function that fires the HTTP requests +# the test-set should capture. +# +# Layered behaviour: +# - Boots Aerospike CE via docker compose if not already up. +# - Builds the sample binary. +# - Starts `keploy record` in the background; waits for the app to +# answer /health before firing the curls. +# - SIGINTs keploy when curls are done. +# - Normalises the recorded test-set path to ./keploy/. +# - Adds `body.duration: []` to noise on any /parallel, /multiclient +# or /freshclient test in the set (the response carries wall-clock +# duration that drifts every run). +# - Replays the test-set and exits non-zero if any case fails. + +set -euo pipefail + +# -- knobs that pipelines can override ------------------------------ +: "${KEPLOY:=sudo keploy}" # binary + auth invocation +: "${PORT:=8090}" # HTTP port the sample listens on +: "${LOG_DIR:=/tmp}" # where to drop keploy log output +: "${SKIP_DOCKER:=}" # set=1 to skip docker compose up +: "${SKIP_BUILD:=}" # set=1 to skip go build + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +bring_up_aerospike() { + [ -n "${SKIP_DOCKER:-}" ] && return 0 + echo "==> docker compose up -d aerospike" + docker compose up -d aerospike + # Wait for the healthcheck. + for _ in $(seq 1 30); do + if docker compose ps aerospike --format '{{.Health}}' 2>/dev/null | grep -q healthy; then + return 0 + fi + sleep 2 + done + echo "ERROR: aerospike never reported healthy" >&2 + docker compose logs aerospike | tail -30 >&2 + return 1 +} + +build_app() { + [ -n "${SKIP_BUILD:-}" ] && return 0 + echo "==> go build -o ./aerospike-tls ." + go build -o ./aerospike-tls . +} + +wait_for_app_ready() { + echo "==> waiting for the sample to answer on :$PORT" + for _ in $(seq 1 60); do + if curl -sf -o /dev/null --max-time 1 "http://127.0.0.1:$PORT/health"; then + sleep 3 # extra grace for ingress forwarding to be wired + return 0 + fi + sleep 1 + done + echo "ERROR: app never answered /health" >&2 + return 1 +} + +stop_keploy() { + sudo pkill -SIGINT keploy 2>/dev/null || true + # Give keploy time to flush captured tests + shut down cleanly. + for _ in $(seq 1 15); do + if ! pgrep -af "keploy record" >/dev/null 2>&1; then return 0; fi + sleep 1 + done + echo "WARN: keploy didn't exit on SIGINT, killing" + sudo pkill -KILL keploy 2>/dev/null || true +} + +# Two kinds of fixups can be required after a fresh recording: +# +# 1. Some keploy invocations write to ./keploy/keploy/test-set-N +# (nested) instead of ./keploy/test-set-N (flat). Flatten that. +# +# 2. Keploy auto-numbers fresh recordings as the next available +# test-set-N when the target dir doesn't exist yet. So +# script-2.sh asks for test-set-1, but if ./keploy/ is empty at +# record time, keploy will create test-set-0. Rename whichever +# fresh dir landed under ./keploy/ to the requested target. +normalise_recording() { + local target="$1" + if [ -d "./keploy/keploy" ]; then + sudo chown -R "$(id -u):$(id -g)" ./keploy/keploy + for d in ./keploy/keploy/test-set-*; do + [ -d "$d" ] || continue + mv "$d" "./keploy/" + done + rmdir ./keploy/keploy 2>/dev/null || true + fi + [ -d "./keploy/$target" ] && return 0 + + local newest="" newest_mtime=0 + for d in ./keploy/test-set-*; do + [ -d "$d" ] || continue + local m + m=$(stat -c %Y "$d") + if [ "$m" -gt "$newest_mtime" ]; then + newest="$d"; newest_mtime="$m" + fi + done + if [ -n "$newest" ]; then + mv "$newest" "./keploy/$target" + return 0 + fi + echo "ERROR: $target was not recorded — check $LOG_DIR/keploy-record-$target.log" >&2 + return 1 +} + +apply_duration_noise() { + local target="$1" + local applied=0 + for f in ./keploy/"$target"/tests/post-parallel-*.yaml \ + ./keploy/"$target"/tests/post-multiclient-*.yaml \ + ./keploy/"$target"/tests/post-freshclient-*.yaml; do + [ -e "$f" ] || continue + if ! grep -q "body.duration:" "$f"; then + sed -i 's|header.Date: \[\]|header.Date: []\n body.duration: []|' "$f" + applied=$((applied+1)) + fi + done + [ "$applied" -gt 0 ] && echo "==> applied body.duration noise to $applied test(s)" + return 0 +} + +run_test_set() { + local target="$1" # e.g. test-set-0 + local curl_fn="$2" # shell function that fires the requests + + bring_up_aerospike + build_app + + echo "==> clearing any stale ./keploy/$target" + sudo rm -rf "./keploy/$target" + + local log="$LOG_DIR/keploy-record-$target.log" + echo "==> starting keploy record (logging to $log)" + $KEPLOY record > "$log" 2>&1 & + local keploy_pid=$! + trap 'stop_keploy' EXIT + + wait_for_app_ready + echo "==> firing curls for $target" + $curl_fn + sleep 3 + + echo "==> stopping keploy record" + stop_keploy + trap - EXIT + wait "$keploy_pid" 2>/dev/null || true + + normalise_recording "$target" + apply_duration_noise "$target" + + echo "==> $KEPLOY test --test-sets $target" + $KEPLOY test --test-sets "$target" +} diff --git a/aerospike-tls/scripts/script-1.sh b/aerospike-tls/scripts/script-1.sh new file mode 100755 index 00000000..25fbdf54 --- /dev/null +++ b/aerospike-tls/scripts/script-1.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# script-1.sh — record + replay test-set-0. +# +# Captures the single-endpoint CRUD coverage: +# GET /health POST /put GET /get POST /batch/put GET /batch/get +# POST /touch DELETE /key +# +# Output: writes keploy/test-set-0/, then runs `keploy test` +# against it and exits non-zero if any case fails. + +set -euo pipefail +source "$(dirname "$0")/common.sh" + +curls_test_set_0() { + curl -sf -o /dev/null "http://127.0.0.1:$PORT/health" + sleep 1 + curl -sf -o /dev/null -XPOST "http://127.0.0.1:$PORT/put" \ + -H 'Content-Type: application/json' \ + -d '{"key":"alice","bins":{"age":30,"name":"Alice"}}' + sleep 1 + curl -sf -o /dev/null "http://127.0.0.1:$PORT/get/alice" + sleep 1 + curl -sf -o /dev/null -XPOST "http://127.0.0.1:$PORT/batch/put" \ + -H 'Content-Type: application/json' \ + -d '[{"key":"a","bins":{"n":1}},{"key":"b","bins":{"n":2}}]' + sleep 1 + curl -s -o /dev/null "http://127.0.0.1:$PORT/batch/get?k=a&k=b" || true + sleep 1 + curl -sf -o /dev/null -XPOST "http://127.0.0.1:$PORT/touch/alice" + sleep 1 + curl -sf -o /dev/null -XDELETE "http://127.0.0.1:$PORT/key/alice" +} + +run_test_set test-set-0 curls_test_set_0 diff --git a/aerospike-tls/scripts/script-2.sh b/aerospike-tls/scripts/script-2.sh new file mode 100755 index 00000000..51418dde --- /dev/null +++ b/aerospike-tls/scripts/script-2.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# script-2.sh — record + replay test-set-1. +# +# Captures /parallel at four concurrency levels (n = 4, 8, 12, 24) so +# the replay path exercises bursts of concurrent goroutines sharing a +# single *as.Client. + +set -euo pipefail +source "$(dirname "$0")/common.sh" + +curls_test_set_1() { + curl -sf -o /dev/null "http://127.0.0.1:$PORT/health" + sleep 1 + curl -sf -o /dev/null -XPOST "http://127.0.0.1:$PORT/parallel?n=4&prefix=run1" + sleep 2 + curl -sf -o /dev/null -XPOST "http://127.0.0.1:$PORT/parallel?n=8&prefix=run2" + sleep 2 + curl -sf -o /dev/null -XPOST "http://127.0.0.1:$PORT/parallel?n=12&prefix=run3" + sleep 2 + curl -sf -o /dev/null -XPOST "http://127.0.0.1:$PORT/parallel?n=24&prefix=run4" +} + +run_test_set test-set-1 curls_test_set_1 diff --git a/aerospike-tls/scripts/script-3.sh b/aerospike-tls/scripts/script-3.sh new file mode 100755 index 00000000..40324b95 --- /dev/null +++ b/aerospike-tls/scripts/script-3.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# script-3.sh — record + replay test-set-2. +# +# Captures /multiclient (4 pre-built *as.Client, round-robined) and +# /freshclient (per-request *as.Client construction). Demonstrates +# that Keploy replay stays deterministic across more elaborate +# client-shape patterns. + +set -euo pipefail +source "$(dirname "$0")/common.sh" + +curls_test_set_2() { + curl -sf -o /dev/null "http://127.0.0.1:$PORT/health" + sleep 1 + curl -sf -o /dev/null -XPOST "http://127.0.0.1:$PORT/multiclient?n=4&prefix=mc1" + sleep 2 + curl -sf -o /dev/null -XPOST "http://127.0.0.1:$PORT/multiclient?n=8&prefix=mc2" + sleep 2 + curl -sf -o /dev/null -XPOST "http://127.0.0.1:$PORT/multiclient?n=12&prefix=mc3" + sleep 2 + curl -sf -o /dev/null -XPOST "http://127.0.0.1:$PORT/multiclient?n=24&prefix=mc4" + sleep 3 + curl -sf -o /dev/null -XPOST "http://127.0.0.1:$PORT/freshclient?n=4&prefix=fc1" + sleep 3 + curl -sf -o /dev/null -XPOST "http://127.0.0.1:$PORT/freshclient?n=8&prefix=fc2" +} + +run_test_set test-set-2 curls_test_set_2