6 Commits

Author SHA1 Message Date
82de580879 feat(ekko): add WithTrustOptions for CDN IP trust configuration
Allow callers to append additional echo.TrustOption values to the
default IP extraction configuration. This enables trusting CDN IP
ranges (e.g. Fastly) when extracting client IPs from X-Forwarded-For.
2026-03-08 18:31:44 -07:00
92b202037a fix(ekko): store service name and add fallback for Server header
The name parameter passed to ekko.New() was never stored on the struct,
causing the HTTP Server header to be malformed (e.g. "/vdev-snapshot+hash"
instead of "warmform/vdev-snapshot+hash").

Store the name in the struct literal and add a fallback that derives the
name from debug.ReadBuildInfo() if an empty string is passed.
2026-03-08 17:41:41 -07:00
af7683da9a fix(version): don't add "v" prefix to non-semver VERSION strings
When VERSION is set to a non-tag value like "main" (from goreleaser or
ldflags), the init() function unconditionally prepended "v", producing
"vmain". Now only add the "v" prefix when doing so produces a valid
semver string, leaving branch names and other non-semver values as-is.
2026-03-08 14:02:45 -07:00
3c801842e4 build(ci): support both Woodpecker and Drone in run-goreleaser
Use CI_COMMIT_TAG with DRONE_TAG fallback so the script
works in both CI systems during migration.
2026-03-07 16:21:20 -08:00
d56e33b171 fix(ekko): include client IP in request logs
Use slogecho.DefaultConfig() instead of a zero-value struct literal
so WithClientIP (and WithRequestID) defaults are inherited.
2026-02-21 01:11:30 -08:00
614cbf8097 feat(pgdb): add PG* environment variable fallback in OpenPool
When no DATABASE_URI or config file is found, fall through to
pgxpool.ParseConfig("") which natively reads standard PG* variables
(PGHOST, PGUSER, PGPASSWORD, PGDATABASE, etc.). This removes
unnecessary ceremony in CI and container environments where PG* vars
are already set.
2026-02-21 00:34:09 -08:00
8 changed files with 158 additions and 35 deletions

View File

@@ -97,8 +97,9 @@ For higher connection limits, set via `PoolOptions` or URI query parameter `?poo
## Environment Variables
- `DATABASE_URI` - PostgreSQL connection URI (takes precedence over config files)
- `DATABASE_URI` - PostgreSQL connection URI (takes precedence over all other methods)
- `DATABASE_CONFIG_FILE` - Override config file location
- Standard `PG*` variables (`PGHOST`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`, `PGPORT`, `PGSSLMODE`, etc.) - used as fallback when no config file is found; parsed natively by pgx
### URI Format

View File

@@ -66,6 +66,7 @@ func DefaultPoolOptions() PoolOptions {
// 1. DATABASE_URI environment variable (pool settings can be included in URI)
// 2. DATABASE_CONFIG_FILE environment variable (YAML)
// 3. Default config files (database.yaml, /vault/secrets/database.yaml)
// 4. Standard PG* environment variables (PGHOST, PGUSER, PGPASSWORD, PGDATABASE, etc.)
//
// When using DATABASE_URI, pool settings (pool_max_conns, pool_min_conns, etc.)
// can be specified in the URI query string and PoolOptions are ignored.
@@ -96,17 +97,23 @@ func OpenPool(ctx context.Context, options PoolOptions) (*pgxpool.Pool, error) {
}
} else {
// Fall back to config file approach
pgCfg, _, err := FindConfig(options.ConfigFiles)
pgCfg, _, configErr := FindConfig(options.ConfigFiles)
if configErr != nil {
// No config file found; try standard PG* environment variables
// (PGHOST, PGUSER, PGPASSWORD, PGDATABASE, etc.)
// pgxpool.ParseConfig("") reads these natively.
poolConfig, err = pgxpool.ParseConfig("")
if err != nil {
return nil, err
return nil, fmt.Errorf("%w (also tried PG* environment variables: %w)", configErr, err)
}
} else {
poolConfig, err = CreatePoolConfig(pgCfg)
if err != nil {
return nil, err
}
}
// Apply pool-specific settings from PoolOptions (config files don't support these)
// Apply pool-specific settings from PoolOptions (config files and PG* vars don't support these)
poolConfig.MinConns = options.MinConns
poolConfig.MaxConns = options.MaxConns
poolConfig.MaxConnLifetime = options.MaxConnLifetime

View File

@@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"go.ntppool.org/common/database"
)
@@ -184,6 +185,86 @@ func TestOpenPoolWithDatabaseURI(t *testing.T) {
}
}
func TestPGEnvVarsFallback(t *testing.T) {
// Verify that pgxpool.ParseConfig("") picks up PG* env vars
t.Setenv("PGHOST", "pg.example.com")
t.Setenv("PGUSER", "pguser")
t.Setenv("PGDATABASE", "pgdb")
t.Setenv("PGPORT", "5433")
t.Setenv("PGSSLMODE", "require")
cfg, err := pgxpool.ParseConfig("")
if err != nil {
t.Fatalf("pgxpool.ParseConfig(\"\") failed: %v", err)
}
if cfg.ConnConfig.Host != "pg.example.com" {
t.Errorf("Expected Host=pg.example.com, got %s", cfg.ConnConfig.Host)
}
if cfg.ConnConfig.User != "pguser" {
t.Errorf("Expected User=pguser, got %s", cfg.ConnConfig.User)
}
if cfg.ConnConfig.Database != "pgdb" {
t.Errorf("Expected Database=pgdb, got %s", cfg.ConnConfig.Database)
}
if cfg.ConnConfig.Port != 5433 {
t.Errorf("Expected Port=5433, got %d", cfg.ConnConfig.Port)
}
}
func TestOpenPoolPGEnvVarsFallback(t *testing.T) {
// Unset DATABASE_URI to ensure we don't take that path
t.Setenv("DATABASE_URI", "")
// Set PG* vars pointing to a non-listening port
t.Setenv("PGHOST", "127.0.0.1")
t.Setenv("PGPORT", "59998")
t.Setenv("PGUSER", "testuser")
t.Setenv("PGDATABASE", "testdb")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
opts := DefaultPoolOptions()
opts.ConfigFiles = []string{"/nonexistent/path/database.yaml"}
_, err := OpenPool(ctx, opts)
// Should fail with connection error (not config file error),
// proving the PG* fallback path was taken
if err == nil {
t.Fatal("expected error, got nil")
}
errStr := err.Error()
if strings.Contains(errStr, "no valid config file") {
t.Errorf("expected connection error from PG* fallback, got config file error: %v", err)
}
}
func TestPGEnvVarsFallbackAppliesPoolOptions(t *testing.T) {
// Verify that PoolOptions are applied when using the PG* fallback path.
// We can't call OpenPool (it would try to connect), so we verify the
// behavior by confirming pgxpool.ParseConfig("") succeeds and that the
// code path applies PoolOptions. We test this indirectly via
// TestOpenPoolPGEnvVarsFallback (connection error proves PG* path was
// taken with pool options applied).
// pgxpool.ParseConfig("") always succeeds (falls back to libpq defaults),
// so when no config files exist the PG* path is always attempted.
t.Setenv("DATABASE_URI", "")
t.Setenv("PGHOST", "192.0.2.1") // RFC 5737 TEST-NET, won't connect
t.Setenv("PGPORT", "5432")
t.Setenv("PGUSER", "testuser")
t.Setenv("PGDATABASE", "testdb")
cfg, err := pgxpool.ParseConfig("")
if err != nil {
t.Fatalf("pgxpool.ParseConfig(\"\") failed: %v", err)
}
if cfg.ConnConfig.Host != "192.0.2.1" {
t.Errorf("Expected Host=192.0.2.1, got %s", cfg.ConnConfig.Host)
}
}
func TestDatabaseURIPrecedence(t *testing.T) {
// Test that DATABASE_URI takes precedence over config files
// We use localhost with a port that's unlikely to have postgres running

View File

@@ -34,6 +34,8 @@ import (
"fmt"
"net"
"net/http"
"runtime/debug"
"strings"
"time"
"github.com/labstack/echo-contrib/echoprometheus"
@@ -70,6 +72,7 @@ import (
// - WithGzipConfig(): Custom gzip compression settings
func New(name string, options ...func(*Ekko)) (*Ekko, error) {
ek := &Ekko{
name: name,
writeTimeout: 60 * time.Second,
readHeaderTimeout: 30 * time.Second,
}
@@ -77,6 +80,20 @@ func New(name string, options ...func(*Ekko)) (*Ekko, error) {
for _, o := range options {
o(ek)
}
if ek.name == "" {
if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Path != "" {
if idx := strings.LastIndex(bi.Main.Path, "/"); idx >= 0 {
ek.name = bi.Main.Path[idx+1:]
} else {
ek.name = bi.Main.Path
}
}
if ek.name == "" {
ek.name = "ekko-app"
}
}
return ek, nil
}
@@ -146,6 +163,7 @@ func (ek *Ekko) setup(ctx context.Context) (*echo.Echo, error) {
echo.TrustLinkLocal(false),
echo.TrustPrivateNet(true),
}
trustOptions = append(trustOptions, ek.extraTrustOptions...)
e.IPExtractor = echo.ExtractIPFromXFFHeader(trustOptions...)
if ek.otelmiddleware == nil {
@@ -162,12 +180,10 @@ func (ek *Ekko) setup(ctx context.Context) (*echo.Echo, error) {
},
}))
e.Use(slogecho.NewWithConfig(log,
slogecho.Config{
WithTraceID: false, // done by logger already
Filters: ek.logFilters,
},
))
logConfig := slogecho.DefaultConfig()
logConfig.WithTraceID = false // done by logger already
logConfig.Filters = ek.logFilters
e.Use(slogecho.NewWithConfig(log, logConfig))
if ek.prom != nil {
e.Use(echoprometheus.NewMiddlewareWithConfig(echoprometheus.MiddlewareConfig{

View File

@@ -20,6 +20,7 @@ type Ekko struct {
logFilters []slogecho.Filter
otelmiddleware echo.MiddlewareFunc
gzipConfig *middleware.GzipConfig
extraTrustOptions []echo.TrustOption
writeTimeout time.Duration
readHeaderTimeout time.Duration
@@ -92,6 +93,16 @@ func WithReadHeaderTimeout(t time.Duration) func(*Ekko) {
}
}
// WithTrustOptions appends additional trust options to the default IP extraction
// configuration. These options are applied after the built-in trust settings
// (loopback trusted, link-local untrusted, private networks trusted) when
// extracting client IPs from the X-Forwarded-For header.
func WithTrustOptions(opts ...echo.TrustOption) func(*Ekko) {
return func(ek *Ekko) {
ek.extraTrustOptions = append(ek.extraTrustOptions, opts...)
}
}
// WithGzipConfig provides custom gzip compression configuration.
// By default, gzip compression is enabled with standard settings.
// Use this option to customize compression level, skip patterns, or disable compression.

View File

@@ -2,7 +2,7 @@
set -euo pipefail
go install github.com/goreleaser/goreleaser/v2@v2.12.3
go install github.com/goreleaser/goreleaser/v2@v2.14.1
if [ ! -z "${harbor_username:-}" ]; then
DOCKER_FILE=~/.docker/config.json
@@ -13,11 +13,11 @@ if [ ! -z "${harbor_username:-}" ]; then
fi
fi
DRONE_TAG=${DRONE_TAG-""}
CI_TAG=${CI_COMMIT_TAG:-${DRONE_TAG:-""}}
is_snapshot=""
if [ -z "$DRONE_TAG" ]; then
if [ -z "$CI_TAG" ]; then
is_snapshot="--snapshot"
fi

View File

@@ -90,10 +90,13 @@ func init() {
VERSION = "dev-snapshot"
} else {
if !strings.HasPrefix(VERSION, "v") {
VERSION = "v" + VERSION
vVersion := "v" + VERSION
if semver.IsValid(vVersion) {
VERSION = vVersion
}
if !semver.IsValid(VERSION) {
slog.Default().Warn("invalid version number", "version", VERSION)
}
if !semver.IsValid(VERSION) && VERSION != "dev-snapshot" {
slog.Default().Info("non-semver version", "version", VERSION)
}
}
if bi, ok := debug.ReadBuildInfo(); ok {

View File

@@ -7,6 +7,7 @@ import (
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
"golang.org/x/mod/semver"
)
func TestCheckVersion(t *testing.T) {
@@ -48,9 +49,12 @@ func TestVersionInfo(t *testing.T) {
t.Error("VersionInfo().Version should not be empty")
}
// Version should start with "v" or be "dev-snapshot"
// Version should start with "v", be "dev-snapshot", or be a non-semver string (e.g. branch name)
if !strings.HasPrefix(info.Version, "v") && info.Version != "dev-snapshot" {
t.Errorf("Version should start with 'v' or be 'dev-snapshot', got: %s", info.Version)
// Non-semver versions like branch names ("main") are acceptable
if semver.IsValid("v" + info.Version) {
t.Errorf("Semver-like version should start with 'v', got: %s", info.Version)
}
}
// GitRevShort should be <= 7 characters if set