Add valkey/redis driver with operator-secret auto-configuration
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/push/pre-commit Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

Apps fronted by valkey-operator instances need the same wait-for-ready
initContainer postgres workloads already get, and wiring per-field
secretKeyRefs for operator-generated secrets is boilerplate. waitfordb
now speaks enough RESP to AUTH and PING, and natively understands the
secret shapes CNPG and valkey-operator generate so an initContainer is
just envFrom plus a mode variable.

- valkey driver (alias: redis): fresh TCP connection per attempt,
  optional AUTH (ACL user or default), PING, reusing the existing
  retry/backoff wait loop; redis:///valkey:// DSNs; default port 6379
- WAITFORDB_SECRET_FORMAT=cnpg|valkey for envFrom-injected operator
  secrets: CNPG <cluster>-app host/port/dbname/user/password keys, and
  valkey-operator key-per-username secrets (_operator preferred, or
  WAITFORDB_USER's same-named key)
- autodetection from injected keys (CNPG keys -> postgres, valkey keys
  -> valkey); explicit WAITFORDB_DRIVER/WAITFORDB_* always win, PG*
  fallback and all existing flags unchanged
- valkey needs no user/database to be valid (unauthenticated PING)
- tests: secret shape parsing/autodetect/mismatch, fake RESP server
  covering NOAUTH/WRONGPASS/ACL auth/DSN, retry-until-up wait
- README: envFrom initContainer snippets for CNPG and valkey-operator
This commit is contained in:
unkin-agent
2026-08-23 16:36:52 +10:00
parent 8654dab582
commit a466b3e07f
7 changed files with 720 additions and 46 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ import (
)
func TestGetRegisteredDrivers(t *testing.T) {
for _, name := range []string{"postgres", "mysql"} {
for _, name := range []string{"postgres", "mysql", "valkey"} {
if _, err := Get(name); err != nil {
t.Errorf("Get(%q) failed: %v", name, err)
}
+133
View File
@@ -0,0 +1,133 @@
package driver
import (
"bufio"
"context"
"fmt"
"io"
"net"
"net/url"
"strconv"
"strings"
"git.unkin.net/unkin/waitfordb/internal/config"
)
func init() { register(valkey{}) }
type valkey struct{}
func (valkey) Name() string { return "valkey" }
func (valkey) Open(cfg config.Config) (Pinger, error) {
p := &valkeyPinger{
addr: net.JoinHostPort(cfg.Host, cfg.Port),
user: cfg.User,
password: cfg.Password,
}
if cfg.DSN != "" {
u, err := url.Parse(cfg.DSN)
if err != nil {
return nil, fmt.Errorf("invalid valkey DSN: %w", err)
}
if u.Scheme != "redis" && u.Scheme != "valkey" {
return nil, fmt.Errorf("unsupported valkey DSN scheme %q (use redis:// or valkey://)", u.Scheme)
}
port := u.Port()
if port == "" {
port = "6379"
}
p.addr = net.JoinHostPort(u.Hostname(), port)
if u.User != nil {
p.user = u.User.Username()
if pw, ok := u.User.Password(); ok {
p.password = pw
}
}
}
return p, nil
}
// valkeyPinger speaks just enough RESP to AUTH and PING. Each Ping uses a
// fresh connection so a half-open socket from a failed attempt cannot linger.
type valkeyPinger struct {
addr string
user string
password string
}
func (p *valkeyPinger) Ping(ctx context.Context) error {
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", p.addr)
if err != nil {
return err
}
defer conn.Close()
if dl, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(dl)
}
r := bufio.NewReader(conn)
if p.password != "" {
args := []string{"AUTH"}
if p.user != "" && p.user != "default" {
args = append(args, p.user)
}
args = append(args, p.password)
if _, err := roundTrip(conn, r, args...); err != nil {
return fmt.Errorf("AUTH %s: %w", p.user, err)
}
}
reply, err := roundTrip(conn, r, "PING")
if err != nil {
return fmt.Errorf("PING: %w", err)
}
if reply != "PONG" {
return fmt.Errorf("PING: unexpected reply %q", reply)
}
return nil
}
func (p *valkeyPinger) Close() error { return nil }
func roundTrip(w io.Writer, r *bufio.Reader, args ...string) (string, error) {
var b strings.Builder
fmt.Fprintf(&b, "*%d\r\n", len(args))
for _, a := range args {
fmt.Fprintf(&b, "$%d\r\n%s\r\n", len(a), a)
}
if _, err := io.WriteString(w, b.String()); err != nil {
return "", err
}
return readReply(r)
}
func readReply(r *bufio.Reader) (string, error) {
line, err := r.ReadString('\n')
if err != nil {
return "", err
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
return "", fmt.Errorf("empty reply")
}
switch line[0] {
case '+':
return line[1:], nil
case '-':
return "", fmt.Errorf("server error: %s", line[1:])
case '$':
n, err := strconv.Atoi(line[1:])
if err != nil || n < 0 {
return "", fmt.Errorf("unexpected reply %q", line)
}
buf := make([]byte, n+2) // payload + trailing CRLF
if _, err := io.ReadFull(r, buf); err != nil {
return "", err
}
return string(buf[:n]), nil
default:
return "", fmt.Errorf("unexpected reply %q", line)
}
}
+234
View File
@@ -0,0 +1,234 @@
package driver
import (
"bufio"
"context"
"fmt"
"io"
"net"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"git.unkin.net/unkin/waitfordb/internal/config"
"git.unkin.net/unkin/waitfordb/internal/wait"
)
// fakeValkey is a minimal RESP server. With user/password set it rejects
// PING until a matching AUTH arrives, like a real ACL-enabled server.
type fakeValkey struct {
ln net.Listener
user string
password string
// refuseFirst counts connections to close immediately before serving.
refuseFirst atomic.Int32
}
func newFakeValkey(t *testing.T, user, password string) *fakeValkey {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
f := &fakeValkey{ln: ln, user: user, password: password}
t.Cleanup(func() { ln.Close() })
go f.serve()
return f
}
func (f *fakeValkey) addr() string { return f.ln.Addr().String() }
func (f *fakeValkey) serve() {
for {
conn, err := f.ln.Accept()
if err != nil {
return
}
if f.refuseFirst.Add(-1) >= 0 {
conn.Close()
continue
}
go f.handle(conn)
}
}
func (f *fakeValkey) handle(conn net.Conn) {
defer conn.Close()
r := bufio.NewReader(conn)
authed := f.password == ""
for {
cmd, err := readCommand(r)
if err != nil {
return
}
switch strings.ToUpper(cmd[0]) {
case "AUTH":
user, pass := "default", cmd[len(cmd)-1]
if len(cmd) == 3 {
user = cmd[1]
}
wantUser := f.user
if wantUser == "" {
wantUser = "default"
}
if user == wantUser && pass == f.password {
authed = true
fmt.Fprint(conn, "+OK\r\n")
} else {
fmt.Fprint(conn, "-WRONGPASS invalid username-password pair or user is disabled.\r\n")
}
case "PING":
if !authed {
fmt.Fprint(conn, "-NOAUTH Authentication required.\r\n")
} else {
fmt.Fprint(conn, "+PONG\r\n")
}
default:
fmt.Fprint(conn, "-ERR unknown command\r\n")
}
}
}
func readCommand(r *bufio.Reader) ([]string, error) {
head, err := r.ReadString('\n')
if err != nil {
return nil, err
}
head = strings.TrimRight(head, "\r\n")
if !strings.HasPrefix(head, "*") {
return nil, fmt.Errorf("bad command header %q", head)
}
n, err := strconv.Atoi(head[1:])
if err != nil || n < 1 {
return nil, fmt.Errorf("bad command header %q", head)
}
out := make([]string, 0, n)
for range n {
size, err := r.ReadString('\n')
if err != nil {
return nil, err
}
size = strings.TrimRight(size, "\r\n")
length, err := strconv.Atoi(strings.TrimPrefix(size, "$"))
if err != nil {
return nil, fmt.Errorf("bad bulk header %q", size)
}
buf := make([]byte, length+2)
if _, err := io.ReadFull(r, buf); err != nil {
return nil, err
}
out = append(out, string(buf[:length]))
}
return out, nil
}
func openValkey(t *testing.T, cfg config.Config) Pinger {
t.Helper()
drv, err := Get("valkey")
if err != nil {
t.Fatalf("Get(valkey): %v", err)
}
p, err := drv.Open(cfg)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { p.Close() })
return p
}
func splitAddr(t *testing.T, addr string) (host, port string) {
t.Helper()
host, port, err := net.SplitHostPort(addr)
if err != nil {
t.Fatalf("split %q: %v", addr, err)
}
return host, port
}
func pingCtx(t *testing.T) context.Context {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
t.Cleanup(cancel)
return ctx
}
func TestValkeyPingNoAuth(t *testing.T) {
f := newFakeValkey(t, "", "")
host, port := splitAddr(t, f.addr())
p := openValkey(t, config.Config{Host: host, Port: port})
if err := p.Ping(pingCtx(t)); err != nil {
t.Fatalf("ping: %v", err)
}
}
func TestValkeyPingDefaultUserAuth(t *testing.T) {
f := newFakeValkey(t, "", "sekrit")
host, port := splitAddr(t, f.addr())
p := openValkey(t, config.Config{Host: host, Port: port, Password: "sekrit"})
if err := p.Ping(pingCtx(t)); err != nil {
t.Fatalf("ping with correct password: %v", err)
}
unauth := openValkey(t, config.Config{Host: host, Port: port})
if err := unauth.Ping(pingCtx(t)); err == nil || !strings.Contains(err.Error(), "NOAUTH") {
t.Fatalf("ping without password: want NOAUTH error, got %v", err)
}
wrong := openValkey(t, config.Config{Host: host, Port: port, Password: "nope"})
if err := wrong.Ping(pingCtx(t)); err == nil || !strings.Contains(err.Error(), "WRONGPASS") {
t.Fatalf("ping with wrong password: want WRONGPASS error, got %v", err)
}
}
func TestValkeyPingACLUserAuth(t *testing.T) {
f := newFakeValkey(t, "_operator", "op-pass")
host, port := splitAddr(t, f.addr())
p := openValkey(t, config.Config{Host: host, Port: port, User: "_operator", Password: "op-pass"})
if err := p.Ping(pingCtx(t)); err != nil {
t.Fatalf("ping as _operator: %v", err)
}
wrong := openValkey(t, config.Config{Host: host, Port: port, User: "other", Password: "op-pass"})
if err := wrong.Ping(pingCtx(t)); err == nil {
t.Fatal("ping as wrong user should fail")
}
}
func TestValkeyDSN(t *testing.T) {
f := newFakeValkey(t, "app", "pw")
p := openValkey(t, config.Config{DSN: "redis://app:pw@" + f.addr()})
if err := p.Ping(pingCtx(t)); err != nil {
t.Fatalf("ping via DSN: %v", err)
}
}
func TestValkeyDSNBadScheme(t *testing.T) {
drv, _ := Get("valkey")
if _, err := drv.Open(config.Config{DSN: "postgres://h:5432/d"}); err == nil {
t.Fatal("expected error for non-redis DSN scheme")
}
}
func TestValkeyWaitRetriesUntilUp(t *testing.T) {
f := newFakeValkey(t, "", "pw")
f.refuseFirst.Store(2)
host, port := splitAddr(t, f.addr())
p := openValkey(t, config.Config{Host: host, Port: port, Password: "pw"})
res := wait.Run(context.Background(),
wait.Params{Timeout: 10 * time.Second, Interval: 10 * time.Millisecond, ConnectTimeout: time.Second},
p.Ping,
func(int, error, time.Duration, time.Duration, time.Duration) {},
wait.RealClock{})
if !res.OK {
t.Fatalf("expected readiness, got %+v", res)
}
if res.Attempts < 2 {
t.Errorf("expected retries before success, got %d attempts", res.Attempts)
}
}