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 ## 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 - `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 ### URI Format

View File

@@ -66,6 +66,7 @@ func DefaultPoolOptions() PoolOptions {
// 1. DATABASE_URI environment variable (pool settings can be included in URI) // 1. DATABASE_URI environment variable (pool settings can be included in URI)
// 2. DATABASE_CONFIG_FILE environment variable (YAML) // 2. DATABASE_CONFIG_FILE environment variable (YAML)
// 3. Default config files (database.yaml, /vault/secrets/database.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.) // 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. // 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 { } else {
// Fall back to config file approach // Fall back to config file approach
pgCfg, _, err := FindConfig(options.ConfigFiles) pgCfg, _, configErr := FindConfig(options.ConfigFiles)
if err != nil { if configErr != nil {
return nil, err // 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, fmt.Errorf("%w (also tried PG* environment variables: %w)", configErr, err)
}
} else {
poolConfig, err = CreatePoolConfig(pgCfg)
if err != nil {
return nil, err
}
} }
poolConfig, err = CreatePoolConfig(pgCfg) // Apply pool-specific settings from PoolOptions (config files and PG* vars don't support these)
if err != nil {
return nil, err
}
// Apply pool-specific settings from PoolOptions (config files don't support these)
poolConfig.MinConns = options.MinConns poolConfig.MinConns = options.MinConns
poolConfig.MaxConns = options.MaxConns poolConfig.MaxConns = options.MaxConns
poolConfig.MaxConnLifetime = options.MaxConnLifetime poolConfig.MaxConnLifetime = options.MaxConnLifetime

View File

@@ -7,6 +7,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/jackc/pgx/v5/pgxpool"
"go.ntppool.org/common/database" "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) { func TestDatabaseURIPrecedence(t *testing.T) {
// Test that DATABASE_URI takes precedence over config files // Test that DATABASE_URI takes precedence over config files
// We use localhost with a port that's unlikely to have postgres running // We use localhost with a port that's unlikely to have postgres running

View File

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

View File

@@ -13,13 +13,14 @@ import (
// It encapsulates server configuration, middleware options, and lifecycle management // It encapsulates server configuration, middleware options, and lifecycle management
// for NTP Pool web services. Use New() with functional options to configure. // for NTP Pool web services. Use New() with functional options to configure.
type Ekko struct { type Ekko struct {
name string name string
prom prometheus.Registerer prom prometheus.Registerer
port int port int
routeFn func(e *echo.Echo) error routeFn func(e *echo.Echo) error
logFilters []slogecho.Filter logFilters []slogecho.Filter
otelmiddleware echo.MiddlewareFunc otelmiddleware echo.MiddlewareFunc
gzipConfig *middleware.GzipConfig gzipConfig *middleware.GzipConfig
extraTrustOptions []echo.TrustOption
writeTimeout time.Duration writeTimeout time.Duration
readHeaderTimeout 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. // WithGzipConfig provides custom gzip compression configuration.
// By default, gzip compression is enabled with standard settings. // By default, gzip compression is enabled with standard settings.
// Use this option to customize compression level, skip patterns, or disable compression. // Use this option to customize compression level, skip patterns, or disable compression.

View File

@@ -2,7 +2,7 @@
set -euo pipefail 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 if [ ! -z "${harbor_username:-}" ]; then
DOCKER_FILE=~/.docker/config.json DOCKER_FILE=~/.docker/config.json
@@ -13,11 +13,11 @@ if [ ! -z "${harbor_username:-}" ]; then
fi fi
fi fi
DRONE_TAG=${DRONE_TAG-""} CI_TAG=${CI_COMMIT_TAG:-${DRONE_TAG:-""}}
is_snapshot="" is_snapshot=""
if [ -z "$DRONE_TAG" ]; then if [ -z "$CI_TAG" ]; then
is_snapshot="--snapshot" is_snapshot="--snapshot"
fi fi

View File

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

View File

@@ -7,6 +7,7 @@ import (
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go" dto "github.com/prometheus/client_model/go"
"golang.org/x/mod/semver"
) )
func TestCheckVersion(t *testing.T) { func TestCheckVersion(t *testing.T) {
@@ -48,9 +49,12 @@ func TestVersionInfo(t *testing.T) {
t.Error("VersionInfo().Version should not be empty") 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" { 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 // GitRevShort should be <= 7 characters if set
@@ -398,10 +402,10 @@ func TestParseBuildTimeConsistency(t *testing.T) {
func BenchmarkParseBuildTime(b *testing.B) { func BenchmarkParseBuildTime(b *testing.B) {
inputs := []string{ inputs := []string{
"1672531200", // Unix epoch "1672531200", // Unix epoch
"2023-01-01T00:00:00Z", // RFC3339 "2023-01-01T00:00:00Z", // RFC3339
"invalid-timestamp", // Invalid "invalid-timestamp", // Invalid
"", // Empty "", // Empty
} }
for _, input := range inputs { for _, input := range inputs {