Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,67 @@ jobs:
- run: cd backend && go test ./...
- run: cd backend && go build -o /dev/null .

runner:
name: pgbench Runner
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: "1.22"
cache-dependency-path: runners/pgbench/go.mod
- name: Vet runner
run: cd runners/pgbench && go vet ./...
- name: Test runner
run: cd runners/pgbench && go test -race ./...
- name: Build runner image
run: docker build -t plugin-bench-pgbench:ci runners/pgbench
- name: Integration test runner
timeout-minutes: 3
shell: bash
run: |
set -euo pipefail
docker network create pgbench-ci
docker run -d --name pgbench-ci-db --network pgbench-ci \
-e POSTGRES_PASSWORD=ci-password -e POSTGRES_DB=pgbench postgres:16-alpine
ready=false
for attempt in {1..30}; do
if docker exec pgbench-ci-db pg_isready -h 127.0.0.1 -U postgres -d pgbench; then
ready=true
break
fi
sleep 1
done
if [ "$ready" != true ]; then
docker logs pgbench-ci-db
exit 1
fi
runner=(docker run --rm --network pgbench-ci
-e PGHOST=pgbench-ci-db -e PGDATABASE=pgbench -e PGUSER=postgres
-e PGCONNECT_TIMEOUT=3 -e BENCH_DURATION=2)
for initialize in true false; do
"${runner[@]}" -e PGPASSWORD=ci-password -e BENCH_INITIALIZE="$initialize" \
plugin-bench-pgbench:ci > benchmark.log 2>&1
cat benchmark.log
grep -Eq '^tps = [0-9]' benchmark.log
grep -Eq '^latency average = [0-9]' benchmark.log
done
if "${runner[@]}" -e PGPASSWORD=wrong-ci-password \
plugin-bench-pgbench:ci > failure.log 2>&1; then
echo "Expected authentication failure"
exit 1
fi
grep -q 'password authentication failed' failure.log
if grep -Fq 'wrong-ci-password' failure.log; then
echo "Failure output exposed the password"
exit 1
fi
- name: Clean up integration resources
if: always()
run: |
docker rm -fv pgbench-ci-db || true
docker network rm pgbench-ci || true

helm-lint:
name: Helm lint
runs-on: ubuntu-latest
Expand Down
26 changes: 26 additions & 0 deletions runners/pgbench/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
FROM golang:1.22-alpine AS builder

WORKDIR /src

COPY go.mod ./
COPY main.go ./

RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags="-s -w" \
-o /out/pgbench-runner \
.

FROM postgres:16-alpine

# pgbench is provided by PostgreSQL's contrib package rather than the server
# package. Verify it during the image build so the runner cannot start without
# its benchmark executable.
RUN apk add --no-cache postgresql16-contrib \
&& pgbench --version

COPY --from=builder --chown=postgres:postgres /out/pgbench-runner /usr/local/bin/pgbench-runner

USER postgres

ENTRYPOINT ["/usr/local/bin/pgbench-runner"]
93 changes: 93 additions & 0 deletions runners/pgbench/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# pgbench Runner

The runner connects to PostgreSQL, optionally initializes pgbench tables, and prints native pgbench results. Results are not stored yet; the coordinator will later run this image as a Kubernetes Job.

## Build

From the repository root:

```bash
docker build -t plugin-bench-pgbench:dev runners/pgbench
```

## Disposable PostgreSQL database

```bash
docker network create pgbench-test
docker run -d \
--name pgbench-db-test \
--network pgbench-test \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=pgbench \
postgres:16-alpine

ready=false
for attempt in {1..30}; do
if docker exec pgbench-db-test pg_isready -U postgres -d pgbench; then
ready=true
break
fi
sleep 1
done
if [ "$ready" != true ]; then
echo "PostgreSQL did not become ready"
exit 1
fi
```

## Configuration

Required connection variables:

```text
PGHOST PostgreSQL hostname
PGDATABASE Database name
PGUSER PostgreSQL user
PGPASSWORD Password, or use PGPASSFILE
```

Optional connection variables include `PGPORT` (default `5432`), `PGCONNECT_TIMEOUT` (default `10`), `PGSSLMODE`, and `PGSSLROOTCERT`.

For `PGPASSFILE`, mount a password file readable by the container's `postgres` user, with mode `0600`, and supply its container path. When set, `PGPASSWORD` takes precedence.

Benchmark variables:

```text
BENCH_INITIALIZE Initialize tables; default false
BENCH_DURATION Duration in seconds; default 60
BENCH_CLIENTS Concurrent clients; default 1
BENCH_THREADS pgbench threads; default 1
BENCH_SCALE Initialization scale; default 1
```

`BENCH_THREADS` cannot exceed `BENCH_CLIENTS`.

## Run

Initialize the disposable database and run a 30-second benchmark. `BENCH_INITIALIZE=true` drops and recreates existing pgbench tables, so use it only with a disposable database:

```bash
docker run --rm \
--network pgbench-test \
-e PGHOST=pgbench-db-test \
-e PGPORT=5432 \
-e PGDATABASE=pgbench \
-e PGUSER=postgres \
-e PGPASSWORD=postgres \
-e BENCH_INITIALIZE=true \
-e BENCH_DURATION=30 \
-e BENCH_CLIENTS=2 \
-e BENCH_THREADS=2 \
plugin-bench-pgbench:dev
```

The output includes `tps`, average latency, and failed transactions. Run again without `BENCH_INITIALIZE` to reuse the tables.

The runner passes connection settings through PostgreSQL environment variables and does not put credentials in command-line arguments. It returns a non-zero exit code for invalid configuration, connection failures, initialization failures, or benchmark failures.

## Cleanup

```bash
docker rm -f pgbench-db-test
docker network rm pgbench-test
```
3 changes: 3 additions & 0 deletions runners/pgbench/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/openeverest/plugin-bench/runners/pgbench

go 1.22
153 changes: 153 additions & 0 deletions runners/pgbench/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package main

import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
)

type config struct {
port, timeout, duration, clients, threads, scale int
initialize bool
}

func loadConfig(getenv func(string) string) (config, error) {
var c config
for _, name := range []string{"PGHOST", "PGDATABASE", "PGUSER"} {
if strings.TrimSpace(getenv(name)) == "" {
return c, fmt.Errorf("%s is required", name)
}
}
if getenv("PGPASSWORD") == "" && getenv("PGPASSFILE") == "" {
return c, errors.New("PGPASSWORD or PGPASSFILE is required")
}
if getenv("PGPASSWORD") == "" {
path := getenv("PGPASSFILE")
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() {
return c, errors.New("PGPASSFILE must refer to an existing regular file")
}
// libpq ignores password files accessible by group or other users.
if info.Mode().Perm()&0077 != 0 {
return c, errors.New("PGPASSFILE must not allow group or other access (use mode 0600)")
}
file, err := os.Open(path)
if err != nil {
return c, errors.New("PGPASSFILE must be readable by the runner user")
}
file.Close()
}
for _, field := range []struct {
name string
fallback, maximum int
target *int
}{
{"PGPORT", 5432, 65535, &c.port},
{"PGCONNECT_TIMEOUT", 10, 2147483647, &c.timeout},
{"BENCH_DURATION", 60, 2147483647, &c.duration},
{"BENCH_CLIENTS", 1, 2147483647, &c.clients},
{"BENCH_THREADS", 1, 2147483647, &c.threads},
{"BENCH_SCALE", 1, 2147483647, &c.scale},
} {
value := getenv(field.name)
if value == "" {
*field.target = field.fallback
continue
}
n, err := strconv.ParseUint(value, 10, 31)
if err != nil || n == 0 || n > uint64(field.maximum) {
return c, fmt.Errorf("%s must be an integer between 1 and %d", field.name, field.maximum)
}
*field.target = int(n)
}
switch getenv("BENCH_INITIALIZE") {
case "", "false":
case "true":
c.initialize = true
default:
return c, errors.New("BENCH_INITIALIZE must be true or false")
}
if c.threads > c.clients {
return c, errors.New("BENCH_THREADS cannot exceed BENCH_CLIENTS")
}
return c, nil
}

// commandEnvironment preserves connection and TLS settings, replacing defaulted
// values without putting credentials into command arguments.
func commandEnvironment(env []string, c config) []string {
result := make([]string, 0, len(env)+2)
for _, entry := range env {
if !strings.HasPrefix(entry, "PGPORT=") && !strings.HasPrefix(entry, "PGCONNECT_TIMEOUT=") {
result = append(result, entry)
}
}
return append(result, "PGPORT="+strconv.Itoa(c.port), "PGCONNECT_TIMEOUT="+strconv.Itoa(c.timeout))
}

// execute streams pgbench output and terminates the child when the runner is
// cancelled. A child that ignores SIGTERM is killed after five seconds.
func execute(ctx context.Context, executable string, args, env []string, stdout, stderr io.Writer) error {
cmd := exec.CommandContext(ctx, executable, args...)
cmd.Env = env
cmd.Stdout, cmd.Stderr = stdout, stderr
cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
cmd.WaitDelay = 5 * time.Second
return cmd.Run()
}

func run(ctx context.Context, c config, invoke func(context.Context, []string) error) error {
if c.initialize {
if err := invoke(ctx, []string{"-i", "-s", strconv.Itoa(c.scale)}); err != nil {
return fmt.Errorf("initialization failed: %w", err)
}
}
if err := ctx.Err(); err != nil {
return err
}
return invoke(ctx, []string{"-c", strconv.Itoa(c.clients), "-j", strconv.Itoa(c.threads), "-T", strconv.Itoa(c.duration)})
}

func runnerMain() int {
c, err := loadConfig(os.Getenv)
if err != nil {
fmt.Fprintln(os.Stderr, "configuration error:", err)
return 1
}
executable, err := exec.LookPath("pgbench")
if err != nil {
fmt.Fprintln(os.Stderr, "runner error: pgbench executable was not found")
return 1
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
env := commandEnvironment(os.Environ(), c)
err = run(ctx, c, func(ctx context.Context, args []string) error {
return execute(ctx, executable, args, env, os.Stdout, os.Stderr)
})
if ctx.Err() != nil {
fmt.Fprintln(os.Stderr, "runner cancelled")
return 1
}
if err != nil {
fmt.Fprintln(os.Stderr, "runner error: pgbench execution failed")
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() > 0 {
return exitErr.ExitCode()
}
return 1
}
return 0
}

func main() {
os.Exit(runnerMain())
}
Loading
Loading