Compare commits
8 Commits
Author | SHA1 | Date | |
---|---|---|---|
09b52f92d7 | |||
785abdec8d | |||
ce203a4618 | |||
3c994a7343 | |||
f69c3e9c3c | |||
fac5b1f275 | |||
a37559b93e | |||
faac09ac0c |
1
.github/copilot-instructions.md
vendored
Symbolic link
1
.github/copilot-instructions.md
vendored
Symbolic link
@ -0,0 +1 @@
|
||||
../CLAUDE.md
|
163
CLAUDE.md
Normal file
163
CLAUDE.md
Normal file
@ -0,0 +1,163 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
### Testing
|
||||
- Run all tests: `go test ./...`
|
||||
- Run tests with verbose output: `go test -v ./...`
|
||||
- Run tests for specific package: `go test ./config`
|
||||
- Run specific test: `go test -run TestConfigBool ./config`
|
||||
|
||||
### Building
|
||||
- Build all packages: `go build ./...`
|
||||
- Check module dependencies: `go mod tidy`
|
||||
- Verify dependencies: `go mod verify`
|
||||
|
||||
### Code Quality
|
||||
- Format code: `go fmt ./...`
|
||||
- Vet code: `go vet ./...`
|
||||
- Run static analysis: `staticcheck ./...` (if available)
|
||||
|
||||
## Architecture
|
||||
|
||||
This is a common library (`go.ntppool.org/common`) providing shared infrastructure for the NTP Pool project. The codebase emphasizes observability, security, and modern Go practices.
|
||||
|
||||
### Core Components
|
||||
|
||||
**Web Service Foundation:**
|
||||
- `ekko/` - Enhanced Echo web framework with pre-configured middleware (OpenTelemetry, Prometheus, logging, security headers)
|
||||
- `health/` - Standalone health check HTTP server with `/__health` endpoint
|
||||
- `metricsserver/` - Prometheus metrics exposure via `/metrics` endpoint
|
||||
|
||||
**Observability Stack:**
|
||||
- `logger/` - Structured logging with OpenTelemetry trace integration and multiple output formats
|
||||
- `tracing/` - OpenTelemetry distributed tracing with OTLP export support
|
||||
- `metricsserver/` - Prometheus metrics with custom registry
|
||||
|
||||
**Configuration & Environment:**
|
||||
- `config/` - Environment-based configuration with code-generated accessors (`config_accessor.go`)
|
||||
- `version/` - Build metadata and version information with Cobra CLI integration
|
||||
|
||||
**Security & Communication:**
|
||||
- `apitls/` - TLS certificate management with automatic renewal via certman
|
||||
- `kafka/` - Kafka client wrapper with TLS support for log streaming
|
||||
- `xff/fastlyxff/` - Fastly CDN IP range management for trusted proxy handling
|
||||
|
||||
**Utilities:**
|
||||
- `ulid/` - Thread-safe ULID generation with monotonic ordering
|
||||
- `timeutil/` - JSON-serializable duration types
|
||||
- `types/` - Shared data structures (LogScoreAttributes for NTP server scoring)
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**Functional Options:** Used extensively in `ekko/` for flexible service configuration
|
||||
**Interface-Based Design:** `CertificateProvider` in `apitls/` for pluggable certificate management
|
||||
**Context Propagation:** Throughout the codebase for cancellation and tracing
|
||||
**Graceful Shutdown:** Implemented in web servers and background services
|
||||
|
||||
### Dependencies
|
||||
|
||||
The codebase heavily uses:
|
||||
- Echo web framework with custom middleware stack
|
||||
- OpenTelemetry for observability (traces, metrics, logs)
|
||||
- Prometheus for metrics collection
|
||||
- Kafka for message streaming
|
||||
- Cobra for CLI applications
|
||||
|
||||
### Code Generation
|
||||
|
||||
`config/config_accessor.go` is generated - modify `config.go` and regenerate accessors when adding new configuration options.
|
||||
|
||||
## Package Overview
|
||||
|
||||
### `apitls/`
|
||||
TLS certificate management with automatic renewal support via certman. Provides a CA pool for trusted certificates and interfaces for pluggable certificate providers. Used for secure inter-service communication.
|
||||
|
||||
### `config/`
|
||||
Environment-based configuration system with code-generated accessor methods. Handles deployment mode, hostname configuration, and TLS settings. Provides URL building utilities for web and management interfaces.
|
||||
|
||||
### `ekko/`
|
||||
Enhanced Echo web framework wrapper with pre-configured middleware stack including OpenTelemetry tracing, Prometheus metrics, structured logging, gzip compression, and security headers. Supports HTTP/2 with graceful shutdown.
|
||||
|
||||
### `health/`
|
||||
Standalone HTTP health check server that runs independently from the main application. Exposes `/__health` endpoint with configurable health handlers, timeouts, and graceful shutdown capabilities.
|
||||
|
||||
### `kafka/`
|
||||
Kafka client wrapper with TLS support for secure log streaming. Provides connection management, broker discovery, and reader/writer factories with compression and batching optimizations.
|
||||
|
||||
### `logger/`
|
||||
Structured logging system with OpenTelemetry trace integration. Supports multiple output formats (text, OTLP) with configurable log levels, systemd compatibility, and context-aware logging.
|
||||
|
||||
### `metricsserver/`
|
||||
Dedicated Prometheus metrics HTTP server with custom registry isolation. Exposes `/metrics` endpoint with OpenMetrics support and graceful shutdown handling.
|
||||
|
||||
### `timeutil/`
|
||||
JSON-serializable duration types that support both string parsing ("30s", "5m") and numeric nanosecond values. Compatible with configuration files and REST APIs.
|
||||
|
||||
### `tracing/`
|
||||
OpenTelemetry distributed tracing setup with support for OTLP export via gRPC or HTTP. Handles resource detection, propagation, and automatic instrumentation with configurable TLS.
|
||||
|
||||
### `types/`
|
||||
Shared data structures for the NTP Pool project. Currently contains `LogScoreAttributes` for NTP server scoring with JSON and SQL database compatibility.
|
||||
|
||||
### `ulid/`
|
||||
Thread-safe ULID (Universally Unique Lexicographically Sortable Identifier) generation using cryptographically secure randomness. Optimized for simplicity and performance in high-concurrency environments.
|
||||
|
||||
### `version/`
|
||||
Build metadata and version information system with Git integration. Provides CLI commands for Cobra and Kong frameworks, Prometheus build info metrics, and semantic version validation.
|
||||
|
||||
### `xff/fastlyxff/`
|
||||
Fastly CDN IP range management for trusted proxy handling. Parses Fastly's IP ranges JSON file and generates Echo framework trust options for proper client IP extraction.
|
||||
|
||||
## Go Development Best Practices
|
||||
|
||||
### Code Style
|
||||
- Follow standard Go formatting (`go fmt ./...`)
|
||||
- Use `go vet ./...` for static analysis
|
||||
- Run `staticcheck ./...` when available
|
||||
- Prefer short, descriptive variable names
|
||||
- Use interfaces for testability and flexibility
|
||||
|
||||
### Error Handling
|
||||
- Always handle errors explicitly
|
||||
- Use `errors.Join()` for combining multiple errors
|
||||
- Wrap errors with context using `fmt.Errorf("context: %w", err)`
|
||||
- Return early on errors to reduce nesting
|
||||
|
||||
### Testing
|
||||
- Write table-driven tests when testing multiple scenarios
|
||||
- Use `t.Helper()` in test helper functions
|
||||
- Test error conditions, not just happy paths
|
||||
- Use `testing.Short()` for integration tests that can be skipped
|
||||
|
||||
### Concurrency
|
||||
- Use contexts for cancellation and timeouts
|
||||
- Prefer channels for communication over shared memory
|
||||
- Use `sync.Once` for one-time initialization
|
||||
- Always call `defer cancel()` after `context.WithCancel()`
|
||||
|
||||
### Performance
|
||||
- Use `sync.Pool` for frequently allocated objects
|
||||
- Prefer slices over arrays for better performance
|
||||
- Use `strings.Builder` for string concatenation in loops
|
||||
- Profile before optimizing with `go tool pprof`
|
||||
|
||||
### Observability
|
||||
- Use structured logging with key-value pairs
|
||||
- Add OpenTelemetry spans for external calls
|
||||
- Include trace IDs in error messages
|
||||
- Use metrics for monitoring application health
|
||||
|
||||
### Dependencies
|
||||
- Keep dependencies minimal and well-maintained
|
||||
- Use `go mod tidy` to clean up unused dependencies
|
||||
- Pin major versions to avoid breaking changes
|
||||
- Prefer standard library when possible
|
||||
|
||||
### Security
|
||||
- Never log sensitive information (passwords, tokens)
|
||||
- Use `crypto/rand` for cryptographic randomness
|
||||
- Validate all inputs at API boundaries
|
||||
- Use TLS for all network communication
|
20
README.md
Normal file
20
README.md
Normal file
@ -0,0 +1,20 @@
|
||||
|
||||
Common library for the NTP Pool project with shared infrastructure components.
|
||||
|
||||
## Packages
|
||||
|
||||
- **apitls** - TLS setup for NTP Pool internal services with embedded CA
|
||||
- **config** - NTP Pool project configuration with environment variables
|
||||
- **ekko** - Enhanced Echo web framework with observability middleware
|
||||
- **health** - Standalone health check HTTP server
|
||||
- **kafka** - Kafka client wrapper with TLS support
|
||||
- **logger** - Structured logging with OpenTelemetry integration
|
||||
- **metricsserver** - Prometheus metrics HTTP server
|
||||
- **timeutil** - JSON-serializable duration types
|
||||
- **tracing** - OpenTelemetry distributed tracing setup
|
||||
- **types** - Shared data structures for NTP Pool
|
||||
- **ulid** - Thread-safe ULID generation
|
||||
- **version** - Build metadata and version information
|
||||
- **xff/fastlyxff** - Fastly CDN IP range management
|
||||
|
||||
[](https://pkg.go.dev/go.ntppool.org/common)
|
@ -1,3 +1,9 @@
|
||||
// Package health provides a standalone HTTP server for health checks.
|
||||
//
|
||||
// This package implements a simple health check server that can be used
|
||||
// to expose health status endpoints for monitoring and load balancing.
|
||||
// It supports custom health check handlers and provides structured logging
|
||||
// with graceful shutdown capabilities.
|
||||
package health
|
||||
|
||||
import (
|
||||
@ -11,11 +17,19 @@ import (
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// Server is a standalone HTTP server dedicated to health checks.
|
||||
// It runs separately from the main application server to ensure health
|
||||
// checks remain available even if the main server is experiencing issues.
|
||||
//
|
||||
// The server includes built-in timeouts, graceful shutdown, and structured
|
||||
// logging for monitoring and debugging health check behavior.
|
||||
type Server struct {
|
||||
log *slog.Logger
|
||||
healthFn http.HandlerFunc
|
||||
}
|
||||
|
||||
// NewServer creates a new health check server with the specified health handler.
|
||||
// If healthFn is nil, a default handler that returns HTTP 200 "ok" is used.
|
||||
func NewServer(healthFn http.HandlerFunc) *Server {
|
||||
if healthFn == nil {
|
||||
healthFn = basicHealth
|
||||
@ -27,10 +41,13 @@ func NewServer(healthFn http.HandlerFunc) *Server {
|
||||
return srv
|
||||
}
|
||||
|
||||
// SetLogger replaces the default logger with a custom one.
|
||||
func (srv *Server) SetLogger(log *slog.Logger) {
|
||||
srv.log = log
|
||||
}
|
||||
|
||||
// Listen starts the health server on the specified port and blocks until ctx is cancelled.
|
||||
// The server exposes the health handler at "/__health" with graceful shutdown support.
|
||||
func (srv *Server) Listen(ctx context.Context, port int) error {
|
||||
srv.log.Info("starting health listener", "port", port)
|
||||
|
||||
@ -72,8 +89,7 @@ func (srv *Server) Listen(ctx context.Context, port int) error {
|
||||
return g.Wait()
|
||||
}
|
||||
|
||||
// HealthCheckListener runs simple http server on the specified port for
|
||||
// health check probes
|
||||
// HealthCheckListener runs a simple HTTP server on the specified port for health check probes.
|
||||
func HealthCheckListener(ctx context.Context, port int, log *slog.Logger) error {
|
||||
srv := NewServer(nil)
|
||||
srv.SetLogger(log)
|
||||
|
@ -1,3 +1,8 @@
|
||||
// Package metricsserver provides a standalone HTTP server for exposing Prometheus metrics.
|
||||
//
|
||||
// This package implements a dedicated metrics server that exposes application metrics
|
||||
// via HTTP. It uses a custom Prometheus registry to avoid conflicts with other metric
|
||||
// collectors and provides graceful shutdown capabilities.
|
||||
package metricsserver
|
||||
|
||||
import (
|
||||
@ -13,10 +18,13 @@ import (
|
||||
"go.ntppool.org/common/logger"
|
||||
)
|
||||
|
||||
// Metrics provides a custom Prometheus registry and HTTP handlers for metrics exposure.
|
||||
// It isolates application metrics from the default global registry.
|
||||
type Metrics struct {
|
||||
r *prometheus.Registry
|
||||
}
|
||||
|
||||
// New creates a new Metrics instance with a custom Prometheus registry.
|
||||
func New() *Metrics {
|
||||
r := prometheus.NewRegistry()
|
||||
|
||||
@ -27,10 +35,13 @@ func New() *Metrics {
|
||||
return m
|
||||
}
|
||||
|
||||
// Registry returns the custom Prometheus registry.
|
||||
// Use this to register your application's metrics collectors.
|
||||
func (m *Metrics) Registry() *prometheus.Registry {
|
||||
return m.r
|
||||
}
|
||||
|
||||
// Handler returns an HTTP handler for the /metrics endpoint with OpenMetrics support.
|
||||
func (m *Metrics) Handler() http.Handler {
|
||||
log := logger.NewStdLog("prom http", false, nil)
|
||||
|
||||
@ -41,9 +52,8 @@ func (m *Metrics) Handler() http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// ListenAndServe starts a goroutine with a server running on
|
||||
// the specified port. The server will shutdown and return when
|
||||
// the provided context is done
|
||||
// ListenAndServe starts a metrics server on the specified port and blocks until ctx is done.
|
||||
// The server exposes the metrics handler and shuts down gracefully when the context is cancelled.
|
||||
func (m *Metrics) ListenAndServe(ctx context.Context, port int) error {
|
||||
log := logger.Setup()
|
||||
|
||||
|
242
metricsserver/metrics_test.go
Normal file
242
metricsserver/metrics_test.go
Normal file
@ -0,0 +1,242 @@
|
||||
package metricsserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
metrics := New()
|
||||
|
||||
if metrics == nil {
|
||||
t.Fatal("New returned nil")
|
||||
}
|
||||
|
||||
if metrics.r == nil {
|
||||
t.Error("metrics registry is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry(t *testing.T) {
|
||||
metrics := New()
|
||||
registry := metrics.Registry()
|
||||
|
||||
if registry == nil {
|
||||
t.Fatal("Registry() returned nil")
|
||||
}
|
||||
|
||||
if registry != metrics.r {
|
||||
t.Error("Registry() did not return the metrics registry")
|
||||
}
|
||||
|
||||
// Test that we can register a metric
|
||||
counter := prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "test_counter",
|
||||
Help: "A test counter",
|
||||
})
|
||||
|
||||
err := registry.Register(counter)
|
||||
if err != nil {
|
||||
t.Errorf("failed to register metric: %v", err)
|
||||
}
|
||||
|
||||
// Test that the metric is registered
|
||||
metricFamilies, err := registry.Gather()
|
||||
if err != nil {
|
||||
t.Errorf("failed to gather metrics: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, mf := range metricFamilies {
|
||||
if mf.GetName() == "test_counter" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Error("registered metric not found in registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler(t *testing.T) {
|
||||
metrics := New()
|
||||
|
||||
// Register a test metric
|
||||
counter := prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "test_requests_total",
|
||||
Help: "Total number of test requests",
|
||||
},
|
||||
[]string{"method"},
|
||||
)
|
||||
metrics.Registry().MustRegister(counter)
|
||||
counter.WithLabelValues("GET").Inc()
|
||||
|
||||
// Test the handler
|
||||
handler := metrics.Handler()
|
||||
if handler == nil {
|
||||
t.Fatal("Handler() returned nil")
|
||||
}
|
||||
|
||||
// Create a test request
|
||||
req := httptest.NewRequest("GET", "/metrics", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
// Call the handler
|
||||
handler.ServeHTTP(recorder, req)
|
||||
|
||||
// Check response
|
||||
resp := recorder.Result()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read response body: %v", err)
|
||||
}
|
||||
|
||||
bodyStr := string(body)
|
||||
|
||||
// Check for our test metric
|
||||
if !strings.Contains(bodyStr, "test_requests_total") {
|
||||
t.Error("test metric not found in metrics output")
|
||||
}
|
||||
|
||||
// Check for OpenMetrics format indicators
|
||||
if !strings.Contains(bodyStr, "# TYPE") {
|
||||
t.Error("metrics output missing TYPE comments")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenAndServe(t *testing.T) {
|
||||
metrics := New()
|
||||
|
||||
// Register a test metric
|
||||
counter := prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "test_requests_total",
|
||||
Help: "Total number of test requests",
|
||||
},
|
||||
[]string{"method"},
|
||||
)
|
||||
metrics.Registry().MustRegister(counter)
|
||||
counter.WithLabelValues("GET").Inc()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Start server in a goroutine
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
// Use a high port number to avoid conflicts
|
||||
errCh <- metrics.ListenAndServe(ctx, 9999)
|
||||
}()
|
||||
|
||||
// Give the server a moment to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Test metrics endpoint
|
||||
resp, err := http.Get("http://localhost:9999/metrics")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to GET /metrics: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read response body: %v", err)
|
||||
}
|
||||
|
||||
bodyStr := string(body)
|
||||
|
||||
// Check for our test metric
|
||||
if !strings.Contains(bodyStr, "test_requests_total") {
|
||||
t.Error("test metric not found in metrics output")
|
||||
}
|
||||
|
||||
// Cancel context to stop server
|
||||
cancel()
|
||||
|
||||
// Wait for server to stop
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Errorf("server returned error: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("server did not stop within timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenAndServeContextCancellation(t *testing.T) {
|
||||
metrics := New()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Start server
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- metrics.ListenAndServe(ctx, 9998)
|
||||
}()
|
||||
|
||||
// Give server time to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Cancel context
|
||||
cancel()
|
||||
|
||||
// Server should stop gracefully
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Errorf("server returned error on graceful shutdown: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("server did not stop within timeout after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark the metrics handler response time
|
||||
func BenchmarkMetricsHandler(b *testing.B) {
|
||||
metrics := New()
|
||||
|
||||
// Register some test metrics
|
||||
for i := 0; i < 10; i++ {
|
||||
counter := prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: fmt.Sprintf("bench_counter_%d", i),
|
||||
Help: "A benchmark counter",
|
||||
})
|
||||
metrics.Registry().MustRegister(counter)
|
||||
counter.Add(float64(i * 100))
|
||||
}
|
||||
|
||||
handler := metrics.Handler()
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
req := httptest.NewRequest("GET", "/metrics", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, req)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
b.Fatalf("unexpected status code: %d", recorder.Code)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
// Package timeutil provides JSON-serializable time utilities.
|
||||
package timeutil
|
||||
|
||||
import (
|
||||
@ -6,14 +7,37 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Duration is a wrapper around time.Duration that supports JSON marshaling/unmarshaling.
|
||||
//
|
||||
// When marshaling to JSON, it outputs the duration as a string using time.Duration.String().
|
||||
// When unmarshaling from JSON, it accepts both:
|
||||
// - String values that can be parsed by time.ParseDuration (e.g., "30s", "5m", "1h30m")
|
||||
// - Numeric values that represent nanoseconds as a float64
|
||||
//
|
||||
// This makes it compatible with configuration files and APIs that need to represent
|
||||
// durations in a human-readable format.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// type Config struct {
|
||||
// Timeout timeutil.Duration `json:"timeout"`
|
||||
// }
|
||||
//
|
||||
// // JSON: {"timeout": "30s"}
|
||||
// // or: {"timeout": 30000000000}
|
||||
type Duration struct {
|
||||
time.Duration
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
// It marshals the duration as a string using time.Duration.String().
|
||||
func (d Duration) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(time.Duration(d.Duration).String())
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
// It accepts both string values (parsed via time.ParseDuration) and
|
||||
// numeric values (interpreted as nanoseconds).
|
||||
func (d *Duration) UnmarshalJSON(b []byte) error {
|
||||
var v any
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
|
64
ulid/ulid.go
64
ulid/ulid.go
@ -1,46 +1,44 @@
|
||||
// Package ulid provides thread-safe ULID (Universally Unique Lexicographically Sortable Identifier) generation.
|
||||
//
|
||||
// ULIDs are 128-bit identifiers that are lexicographically sortable and contain
|
||||
// a timestamp component. This package uses cryptographically secure random
|
||||
// generation optimized for simplicity and performance in concurrent environments.
|
||||
package ulid
|
||||
|
||||
import (
|
||||
cryptorand "crypto/rand"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
mathrand "math/rand"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
oklid "github.com/oklog/ulid/v2"
|
||||
"go.ntppool.org/common/logger"
|
||||
)
|
||||
|
||||
var monotonicPool = sync.Pool{
|
||||
New: func() any {
|
||||
log := logger.Setup()
|
||||
|
||||
var seed int64
|
||||
err := binary.Read(cryptorand.Reader, binary.BigEndian, &seed)
|
||||
if err != nil {
|
||||
log.Error("crypto/rand error", "err", err)
|
||||
os.Exit(10)
|
||||
}
|
||||
|
||||
rand := mathrand.New(mathrand.NewSource(seed))
|
||||
|
||||
inc := uint64(mathrand.Int63())
|
||||
|
||||
// log.Printf("seed: %d", seed)
|
||||
// log.Printf("inc: %d", inc)
|
||||
|
||||
// inc = inc & ^uint64(1<<63) // only want 63 bits
|
||||
mono := oklid.Monotonic(rand, inc)
|
||||
return mono
|
||||
},
|
||||
}
|
||||
|
||||
// MakeULID generates a new ULID with the specified timestamp using cryptographically secure randomness.
|
||||
// The function is thread-safe and optimized for high-concurrency environments.
|
||||
//
|
||||
// This implementation prioritizes simplicity and performance over strict monotonicity within
|
||||
// the same millisecond. Each ULID is guaranteed to be unique and lexicographically sortable
|
||||
// across different timestamps.
|
||||
//
|
||||
// Returns a pointer to the generated ULID or an error if generation fails.
|
||||
// Generation should only fail under extreme circumstances (entropy exhaustion).
|
||||
func MakeULID(t time.Time) (*oklid.ULID, error) {
|
||||
mono := monotonicPool.Get().(io.Reader)
|
||||
|
||||
id, err := oklid.New(oklid.Timestamp(t), mono)
|
||||
id, err := oklid.New(oklid.Timestamp(t), cryptorand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
// Make generates a new ULID with the current timestamp using cryptographically secure randomness.
|
||||
// This is a convenience function equivalent to MakeULID(time.Now()).
|
||||
//
|
||||
// The function is thread-safe and optimized for high-concurrency environments.
|
||||
//
|
||||
// Returns a pointer to the generated ULID or an error if generation fails.
|
||||
// Generation should only fail under extreme circumstances (entropy exhaustion).
|
||||
func Make() (*oklid.ULID, error) {
|
||||
id, err := oklid.New(oklid.Now(), cryptorand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -1,25 +1,336 @@
|
||||
package ulid
|
||||
|
||||
import (
|
||||
cryptorand "crypto/rand"
|
||||
"sort"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
oklid "github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
func TestULID(t *testing.T) {
|
||||
func TestMakeULID(t *testing.T) {
|
||||
tm := time.Now()
|
||||
ul1, err := MakeULID(tm)
|
||||
if err != nil {
|
||||
t.Logf("makeULID failed: %s", err)
|
||||
t.Fail()
|
||||
t.Fatalf("MakeULID failed: %s", err)
|
||||
}
|
||||
ul2, err := MakeULID(tm)
|
||||
if err != nil {
|
||||
t.Logf("MakeULID failed: %s", err)
|
||||
t.Fail()
|
||||
t.Fatalf("MakeULID failed: %s", err)
|
||||
}
|
||||
|
||||
if ul1 == nil || ul2 == nil {
|
||||
t.Fatal("MakeULID returned nil ULID")
|
||||
}
|
||||
|
||||
if ul1.String() == ul2.String() {
|
||||
t.Logf("ul1 and ul2 got the same string: %s", ul1.String())
|
||||
t.Fail()
|
||||
t.Errorf("ul1 and ul2 should be different: %s", ul1.String())
|
||||
}
|
||||
|
||||
// Verify they have the same timestamp
|
||||
if ul1.Time() != ul2.Time() {
|
||||
t.Errorf("ULIDs with same input time should have same timestamp: %d != %d", ul1.Time(), ul2.Time())
|
||||
}
|
||||
|
||||
t.Logf("ulid string 1 and 2: %s | %s", ul1.String(), ul2.String())
|
||||
}
|
||||
|
||||
func TestMake(t *testing.T) {
|
||||
// Test Make() function (uses current time)
|
||||
ul1, err := Make()
|
||||
if err != nil {
|
||||
t.Fatalf("Make failed: %s", err)
|
||||
}
|
||||
|
||||
if ul1 == nil {
|
||||
t.Fatal("Make returned nil ULID")
|
||||
}
|
||||
|
||||
// Sleep a bit and generate another
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
|
||||
ul2, err := Make()
|
||||
if err != nil {
|
||||
t.Fatalf("Make failed: %s", err)
|
||||
}
|
||||
|
||||
// Should be different ULIDs
|
||||
if ul1.String() == ul2.String() {
|
||||
t.Errorf("ULIDs from Make() should be different: %s", ul1.String())
|
||||
}
|
||||
|
||||
// Second should be later (or at least not earlier)
|
||||
if ul1.Time() > ul2.Time() {
|
||||
t.Errorf("second ULID should not have earlier timestamp: %d > %d", ul1.Time(), ul2.Time())
|
||||
}
|
||||
|
||||
t.Logf("Make() ULIDs: %s | %s", ul1.String(), ul2.String())
|
||||
}
|
||||
|
||||
func TestMakeULIDUniqueness(t *testing.T) {
|
||||
tm := time.Now()
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for i := 0; i < 1000; i++ {
|
||||
ul, err := MakeULID(tm)
|
||||
if err != nil {
|
||||
t.Fatalf("MakeULID failed on iteration %d: %s", i, err)
|
||||
}
|
||||
|
||||
str := ul.String()
|
||||
if seen[str] {
|
||||
t.Errorf("duplicate ULID generated: %s", str)
|
||||
}
|
||||
seen[str] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeUniqueness(t *testing.T) {
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for i := 0; i < 1000; i++ {
|
||||
ul, err := Make()
|
||||
if err != nil {
|
||||
t.Fatalf("Make failed on iteration %d: %s", i, err)
|
||||
}
|
||||
|
||||
str := ul.String()
|
||||
if seen[str] {
|
||||
t.Errorf("duplicate ULID generated: %s", str)
|
||||
}
|
||||
seen[str] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeULIDTimestampProgression(t *testing.T) {
|
||||
t1 := time.Now()
|
||||
ul1, err := MakeULID(t1)
|
||||
if err != nil {
|
||||
t.Fatalf("MakeULID failed: %s", err)
|
||||
}
|
||||
|
||||
// Wait to ensure different timestamp
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
|
||||
t2 := time.Now()
|
||||
ul2, err := MakeULID(t2)
|
||||
if err != nil {
|
||||
t.Fatalf("MakeULID failed: %s", err)
|
||||
}
|
||||
|
||||
if ul1.Time() >= ul2.Time() {
|
||||
t.Errorf("second ULID should have later timestamp: %d >= %d", ul1.Time(), ul2.Time())
|
||||
}
|
||||
|
||||
if ul1.Compare(*ul2) >= 0 {
|
||||
t.Errorf("second ULID should be greater: %s >= %s", ul1.String(), ul2.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeULIDConcurrency(t *testing.T) {
|
||||
const numGoroutines = 10
|
||||
const numULIDsPerGoroutine = 100
|
||||
|
||||
var wg sync.WaitGroup
|
||||
ulidChan := make(chan *oklid.ULID, numGoroutines*numULIDsPerGoroutine)
|
||||
tm := time.Now()
|
||||
|
||||
// Start multiple goroutines generating ULIDs concurrently
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < numULIDsPerGoroutine; j++ {
|
||||
ul, err := MakeULID(tm)
|
||||
if err != nil {
|
||||
t.Errorf("MakeULID failed: %s", err)
|
||||
return
|
||||
}
|
||||
ulidChan <- ul
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(ulidChan)
|
||||
|
||||
// Collect all ULIDs and check uniqueness
|
||||
seen := make(map[string]bool)
|
||||
count := 0
|
||||
|
||||
for ul := range ulidChan {
|
||||
str := ul.String()
|
||||
if seen[str] {
|
||||
t.Errorf("duplicate ULID generated in concurrent test: %s", str)
|
||||
}
|
||||
seen[str] = true
|
||||
count++
|
||||
}
|
||||
|
||||
if count != numGoroutines*numULIDsPerGoroutine {
|
||||
t.Errorf("expected %d ULIDs, got %d", numGoroutines*numULIDsPerGoroutine, count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeConcurrency(t *testing.T) {
|
||||
const numGoroutines = 10
|
||||
const numULIDsPerGoroutine = 100
|
||||
|
||||
var wg sync.WaitGroup
|
||||
ulidChan := make(chan *oklid.ULID, numGoroutines*numULIDsPerGoroutine)
|
||||
|
||||
// Start multiple goroutines generating ULIDs concurrently
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < numULIDsPerGoroutine; j++ {
|
||||
ul, err := Make()
|
||||
if err != nil {
|
||||
t.Errorf("Make failed: %s", err)
|
||||
return
|
||||
}
|
||||
ulidChan <- ul
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(ulidChan)
|
||||
|
||||
// Collect all ULIDs and check uniqueness
|
||||
seen := make(map[string]bool)
|
||||
count := 0
|
||||
|
||||
for ul := range ulidChan {
|
||||
str := ul.String()
|
||||
if seen[str] {
|
||||
t.Errorf("duplicate ULID generated in concurrent test: %s", str)
|
||||
}
|
||||
seen[str] = true
|
||||
count++
|
||||
}
|
||||
|
||||
if count != numGoroutines*numULIDsPerGoroutine {
|
||||
t.Errorf("expected %d ULIDs, got %d", numGoroutines*numULIDsPerGoroutine, count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeULIDErrorHandling(t *testing.T) {
|
||||
// Test with various timestamps
|
||||
timestamps := []time.Time{
|
||||
time.Unix(0, 0), // Unix epoch
|
||||
time.Now(), // Current time
|
||||
time.Now().Add(time.Hour), // Future time
|
||||
}
|
||||
|
||||
for i, tm := range timestamps {
|
||||
ul, err := MakeULID(tm)
|
||||
if err != nil {
|
||||
t.Errorf("MakeULID failed with timestamp %d: %s", i, err)
|
||||
}
|
||||
if ul == nil {
|
||||
t.Errorf("MakeULID returned nil ULID with timestamp %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeULIDLexicographicOrdering(t *testing.T) {
|
||||
var ulids []*oklid.ULID
|
||||
var timestamps []time.Time
|
||||
|
||||
// Generate ULIDs with increasing timestamps
|
||||
for i := 0; i < 10; i++ {
|
||||
tm := time.Now().Add(time.Duration(i) * time.Millisecond)
|
||||
timestamps = append(timestamps, tm)
|
||||
|
||||
ul, err := MakeULID(tm)
|
||||
if err != nil {
|
||||
t.Fatalf("MakeULID failed: %s", err)
|
||||
}
|
||||
ulids = append(ulids, ul)
|
||||
|
||||
// Small delay to ensure different timestamps
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
// Sort ULID strings lexicographically
|
||||
ulidStrings := make([]string, len(ulids))
|
||||
for i, ul := range ulids {
|
||||
ulidStrings[i] = ul.String()
|
||||
}
|
||||
|
||||
originalOrder := make([]string, len(ulidStrings))
|
||||
copy(originalOrder, ulidStrings)
|
||||
|
||||
sort.Strings(ulidStrings)
|
||||
|
||||
// Verify lexicographic order matches chronological order
|
||||
for i := 0; i < len(originalOrder); i++ {
|
||||
if originalOrder[i] != ulidStrings[i] {
|
||||
t.Errorf("lexicographic order doesn't match chronological order at index %d: %s != %s",
|
||||
i, originalOrder[i], ulidStrings[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark ULID generation performance
|
||||
func BenchmarkMakeULID(b *testing.B) {
|
||||
tm := time.Now()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := MakeULID(tm)
|
||||
if err != nil {
|
||||
b.Fatalf("MakeULID failed: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark Make function
|
||||
func BenchmarkMake(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := Make()
|
||||
if err != nil {
|
||||
b.Fatalf("Make failed: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark concurrent ULID generation
|
||||
func BenchmarkMakeULIDConcurrent(b *testing.B) {
|
||||
tm := time.Now()
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
_, err := MakeULID(tm)
|
||||
if err != nil {
|
||||
b.Fatalf("MakeULID failed: %s", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmark concurrent Make function
|
||||
func BenchmarkMakeConcurrent(b *testing.B) {
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
_, err := Make()
|
||||
if err != nil {
|
||||
b.Fatalf("Make failed: %s", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmark random number generation
|
||||
func BenchmarkCryptoRand(b *testing.B) {
|
||||
buf := make([]byte, 10) // ULID entropy size
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
cryptorand.Read(buf)
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,17 @@
|
||||
// Package version provides build metadata and version information management.
|
||||
//
|
||||
// This package manages application version information including semantic version,
|
||||
// Git revision, build time, and provides integration with CLI frameworks (Cobra, Kong)
|
||||
// and Prometheus metrics for operational visibility.
|
||||
//
|
||||
// Version information can be injected at build time using ldflags:
|
||||
//
|
||||
// go build -ldflags "-X go.ntppool.org/common/version.VERSION=v1.0.0 \
|
||||
// -X go.ntppool.org/common/version.buildTime=2023-01-01T00:00:00Z \
|
||||
// -X go.ntppool.org/common/version.gitVersion=abc123"
|
||||
//
|
||||
// The package also automatically extracts build information from Go's debug.BuildInfo
|
||||
// when available, providing fallback values for VCS time and revision.
|
||||
package version
|
||||
|
||||
import (
|
||||
@ -12,21 +26,25 @@ import (
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// VERSION has the current software version (set in the build process)
|
||||
// VERSION contains the current software version (typically set during the build process via ldflags).
|
||||
// If not set, defaults to "dev-snapshot". The version should follow semantic versioning.
|
||||
var (
|
||||
VERSION string
|
||||
buildTime string
|
||||
gitVersion string
|
||||
gitModified bool
|
||||
VERSION string // Semantic version (e.g., "1.0.0" or "v1.0.0")
|
||||
buildTime string // Build timestamp (RFC3339 format)
|
||||
gitVersion string // Git commit hash
|
||||
gitModified bool // Whether the working tree was modified during build
|
||||
)
|
||||
|
||||
// info holds the consolidated version information extracted from build variables and debug.BuildInfo.
|
||||
var info Info
|
||||
|
||||
// Info represents structured version and build information.
|
||||
// This struct is used for JSON serialization and programmatic access to build metadata.
|
||||
type Info struct {
|
||||
Version string `json:",omitempty"`
|
||||
GitRev string `json:",omitempty"`
|
||||
GitRevShort string `json:",omitempty"`
|
||||
BuildTime string `json:",omitempty"`
|
||||
Version string `json:",omitempty"` // Semantic version with "v" prefix
|
||||
GitRev string `json:",omitempty"` // Full Git commit hash
|
||||
GitRevShort string `json:",omitempty"` // Shortened Git commit hash (7 characters)
|
||||
BuildTime string `json:",omitempty"` // Build timestamp
|
||||
}
|
||||
|
||||
func init() {
|
||||
@ -79,10 +97,16 @@ func init() {
|
||||
Version()
|
||||
}
|
||||
|
||||
// VersionCmd creates a Cobra command for displaying version information.
|
||||
// The name parameter is used as a prefix in the output (e.g., "myapp v1.0.0").
|
||||
// Returns a configured cobra.Command that can be added to any CLI application.
|
||||
func VersionCmd(name string) *cobra.Command {
|
||||
versionCmd := &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print version and build information",
|
||||
Long: `Print detailed version information including semantic version,
|
||||
Git revision, build time, and Go version. Build information is automatically
|
||||
extracted from Go's debug.BuildInfo when available.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
ver := Version()
|
||||
fmt.Printf("%s %s\n", name, ver)
|
||||
@ -91,15 +115,23 @@ func VersionCmd(name string) *cobra.Command {
|
||||
return versionCmd
|
||||
}
|
||||
|
||||
// KongVersionCmd provides a Kong CLI framework compatible version command.
|
||||
// The Name field should be set to the application name for proper output formatting.
|
||||
type KongVersionCmd struct {
|
||||
Name string `kong:"-"`
|
||||
Name string `kong:"-"` // Application name, excluded from Kong parsing
|
||||
}
|
||||
|
||||
// Run executes the version command for Kong CLI framework.
|
||||
// Prints the application name and version information to stdout.
|
||||
func (cmd *KongVersionCmd) Run() error {
|
||||
fmt.Printf("%s %s\n", cmd.Name, Version())
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterMetric registers a Prometheus gauge metric with build information.
|
||||
// If name is provided, it creates a metric named "{name}_build_info", otherwise "build_info".
|
||||
// The metric includes labels for version, build time, Git time, and Git revision.
|
||||
// This is useful for exposing build information in monitoring systems.
|
||||
func RegisterMetric(name string, registry prometheus.Registerer) {
|
||||
if len(name) > 0 {
|
||||
name = strings.ReplaceAll(name, "-", "_")
|
||||
@ -110,13 +142,13 @@ func RegisterMetric(name string, registry prometheus.Registerer) {
|
||||
buildInfo := prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: name,
|
||||
Help: "Build information",
|
||||
Help: "Build information including version, build time, and git revision",
|
||||
},
|
||||
[]string{
|
||||
"version",
|
||||
"buildtime",
|
||||
"gittime",
|
||||
"git",
|
||||
"version", // Combined version/git format (e.g., "v1.0.0/abc123")
|
||||
"buildtime", // Build timestamp from ldflags
|
||||
"gittime", // Git commit timestamp from VCS info
|
||||
"git", // Full Git commit hash
|
||||
},
|
||||
)
|
||||
registry.MustRegister(buildInfo)
|
||||
@ -131,12 +163,20 @@ func RegisterMetric(name string, registry prometheus.Registerer) {
|
||||
).Set(1)
|
||||
}
|
||||
|
||||
// v caches the formatted version string to avoid repeated computation.
|
||||
var v string
|
||||
|
||||
// VersionInfo returns the structured version information.
|
||||
// This provides programmatic access to version details for JSON serialization
|
||||
// or other structured uses.
|
||||
func VersionInfo() Info {
|
||||
return info
|
||||
}
|
||||
|
||||
// Version returns a human-readable version string suitable for display.
|
||||
// The format includes semantic version, Git revision, build time, and Go version.
|
||||
// Example: "v1.0.0/abc123f-M (2023-01-01T00:00:00Z, go1.21.0)"
|
||||
// The "-M" suffix indicates the working tree was modified during build.
|
||||
func Version() string {
|
||||
if len(v) > 0 {
|
||||
return v
|
||||
@ -164,10 +204,20 @@ func Version() string {
|
||||
return v
|
||||
}
|
||||
|
||||
// CheckVersion compares a version against a minimum required version.
|
||||
// Returns true if the version meets or exceeds the minimum requirement.
|
||||
//
|
||||
// Special handling:
|
||||
// - "dev-snapshot" is always considered valid (returns true)
|
||||
// - Git hash suffixes (e.g., "v1.0.0/abc123") are stripped before comparison
|
||||
// - Uses semantic version comparison rules
|
||||
//
|
||||
// Both version and minimumVersion should follow semantic versioning with "v" prefix.
|
||||
func CheckVersion(version, minimumVersion string) bool {
|
||||
if version == "dev-snapshot" {
|
||||
return true
|
||||
}
|
||||
// Strip Git hash suffix if present (e.g., "v1.0.0/abc123" -> "v1.0.0")
|
||||
if idx := strings.Index(version, "/"); idx >= 0 {
|
||||
version = version[0:idx]
|
||||
}
|
||||
|
311
version/version_test.go
Normal file
311
version/version_test.go
Normal file
@ -0,0 +1,311 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
)
|
||||
|
||||
func TestCheckVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
In string
|
||||
Min string
|
||||
Expected bool
|
||||
}{
|
||||
// Basic version comparisons
|
||||
{"v3.8.4", "v3.8.5", false},
|
||||
{"v3.9.3", "v3.8.5", true},
|
||||
{"v3.8.5", "v3.8.5", true},
|
||||
// Dev snapshot should always pass
|
||||
{"dev-snapshot", "v3.8.5", true},
|
||||
{"dev-snapshot", "v99.99.99", true},
|
||||
// Versions with Git hashes should be stripped
|
||||
{"v3.8.5/abc123", "v3.8.5", true},
|
||||
{"v3.8.4/abc123", "v3.8.5", false},
|
||||
{"v3.9.0/def456", "v3.8.5", true},
|
||||
// Pre-release versions
|
||||
{"v3.8.5-alpha", "v3.8.5", false},
|
||||
{"v3.8.5", "v3.8.5-alpha", true},
|
||||
{"v3.8.5-beta", "v3.8.5-alpha", true},
|
||||
}
|
||||
|
||||
for _, d := range tests {
|
||||
r := CheckVersion(d.In, d.Min)
|
||||
if r != d.Expected {
|
||||
t.Errorf("CheckVersion(%q, %q) = %t, expected %t", d.In, d.Min, r, d.Expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionInfo(t *testing.T) {
|
||||
info := VersionInfo()
|
||||
|
||||
// Check that we get a valid Info struct
|
||||
if info.Version == "" {
|
||||
t.Error("VersionInfo().Version should not be empty")
|
||||
}
|
||||
|
||||
// Version should start with "v" or be "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)
|
||||
}
|
||||
|
||||
// GitRevShort should be <= 7 characters if set
|
||||
if info.GitRevShort != "" && len(info.GitRevShort) > 7 {
|
||||
t.Errorf("GitRevShort should be <= 7 characters, got: %s", info.GitRevShort)
|
||||
}
|
||||
|
||||
// GitRevShort should be prefix of GitRev if both are set
|
||||
if info.GitRev != "" && info.GitRevShort != "" {
|
||||
if !strings.HasPrefix(info.GitRev, info.GitRevShort) {
|
||||
t.Errorf("GitRevShort should be prefix of GitRev: %s not prefix of %s",
|
||||
info.GitRevShort, info.GitRev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersion(t *testing.T) {
|
||||
version := Version()
|
||||
|
||||
if version == "" {
|
||||
t.Error("Version() should not return empty string")
|
||||
}
|
||||
|
||||
// Should contain Go version
|
||||
if !strings.Contains(version, runtime.Version()) {
|
||||
t.Errorf("Version should contain Go version %s, got: %s", runtime.Version(), version)
|
||||
}
|
||||
|
||||
// Should contain the VERSION variable (or dev-snapshot)
|
||||
info := VersionInfo()
|
||||
if !strings.Contains(version, info.Version) {
|
||||
t.Errorf("Version should contain %s, got: %s", info.Version, version)
|
||||
}
|
||||
|
||||
// Should be in expected format: "version (extras)"
|
||||
if !strings.Contains(version, "(") || !strings.Contains(version, ")") {
|
||||
t.Errorf("Version should be in format 'version (extras)', got: %s", version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionCmd(t *testing.T) {
|
||||
appName := "testapp"
|
||||
cmd := VersionCmd(appName)
|
||||
|
||||
// Test basic command properties
|
||||
if cmd.Use != "version" {
|
||||
t.Errorf("Expected command use to be 'version', got: %s", cmd.Use)
|
||||
}
|
||||
|
||||
if cmd.Short == "" {
|
||||
t.Error("Command should have a short description")
|
||||
}
|
||||
|
||||
if cmd.Long == "" {
|
||||
t.Error("Command should have a long description")
|
||||
}
|
||||
|
||||
if cmd.Run == nil {
|
||||
t.Error("Command should have a Run function")
|
||||
}
|
||||
|
||||
// Test that the command can be executed without error
|
||||
cmd.SetArgs([]string{})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Errorf("VersionCmd execution should not return error, got: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKongVersionCmd(t *testing.T) {
|
||||
cmd := &KongVersionCmd{Name: "testapp"}
|
||||
|
||||
// Test that Run() doesn't return an error
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
t.Errorf("KongVersionCmd.Run() should not return error, got: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterMetric(t *testing.T) {
|
||||
// Create a test registry
|
||||
registry := prometheus.NewRegistry()
|
||||
|
||||
// Test registering metric without name
|
||||
RegisterMetric("", registry)
|
||||
|
||||
// Gather metrics
|
||||
metricFamilies, err := registry.Gather()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to gather metrics: %s", err)
|
||||
}
|
||||
|
||||
// Find the build_info metric
|
||||
var buildInfoFamily *dto.MetricFamily
|
||||
for _, family := range metricFamilies {
|
||||
if family.GetName() == "build_info" {
|
||||
buildInfoFamily = family
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if buildInfoFamily == nil {
|
||||
t.Fatal("build_info metric not found")
|
||||
}
|
||||
|
||||
if buildInfoFamily.GetHelp() == "" {
|
||||
t.Error("build_info metric should have help text")
|
||||
}
|
||||
|
||||
metrics := buildInfoFamily.GetMetric()
|
||||
if len(metrics) == 0 {
|
||||
t.Fatal("build_info metric should have at least one sample")
|
||||
}
|
||||
|
||||
// Check that the metric has the expected labels
|
||||
metric := metrics[0]
|
||||
labels := metric.GetLabel()
|
||||
|
||||
expectedLabels := []string{"version", "buildtime", "gittime", "git"}
|
||||
labelMap := make(map[string]string)
|
||||
|
||||
for _, label := range labels {
|
||||
labelMap[label.GetName()] = label.GetValue()
|
||||
}
|
||||
|
||||
for _, expectedLabel := range expectedLabels {
|
||||
if _, exists := labelMap[expectedLabel]; !exists {
|
||||
t.Errorf("Expected label %s not found in metric", expectedLabel)
|
||||
}
|
||||
}
|
||||
|
||||
// Check that the metric value is 1
|
||||
if metric.GetGauge().GetValue() != 1 {
|
||||
t.Errorf("Expected build_info metric value to be 1, got %f", metric.GetGauge().GetValue())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterMetricWithName(t *testing.T) {
|
||||
// Create a test registry
|
||||
registry := prometheus.NewRegistry()
|
||||
|
||||
// Test registering metric with custom name
|
||||
appName := "my-test-app"
|
||||
RegisterMetric(appName, registry)
|
||||
|
||||
// Gather metrics
|
||||
metricFamilies, err := registry.Gather()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to gather metrics: %s", err)
|
||||
}
|
||||
|
||||
// Find the my_test_app_build_info metric
|
||||
expectedName := "my_test_app_build_info"
|
||||
var buildInfoFamily *dto.MetricFamily
|
||||
for _, family := range metricFamilies {
|
||||
if family.GetName() == expectedName {
|
||||
buildInfoFamily = family
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if buildInfoFamily == nil {
|
||||
t.Fatalf("%s metric not found", expectedName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionConsistency(t *testing.T) {
|
||||
// Call Version() multiple times and ensure it returns the same result
|
||||
v1 := Version()
|
||||
v2 := Version()
|
||||
|
||||
if v1 != v2 {
|
||||
t.Errorf("Version() should return consistent results: %s != %s", v1, v2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionInfoConsistency(t *testing.T) {
|
||||
// Ensure VersionInfo() is consistent with Version()
|
||||
info := VersionInfo()
|
||||
version := Version()
|
||||
|
||||
// Version string should contain the semantic version
|
||||
if !strings.Contains(version, info.Version) {
|
||||
t.Errorf("Version() should contain VersionInfo().Version: %s not in %s",
|
||||
info.Version, version)
|
||||
}
|
||||
|
||||
// If GitRevShort is set, version should contain it
|
||||
if info.GitRevShort != "" {
|
||||
if !strings.Contains(version, info.GitRevShort) {
|
||||
t.Errorf("Version() should contain GitRevShort: %s not in %s",
|
||||
info.GitRevShort, version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test edge cases
|
||||
func TestCheckVersionEdgeCases(t *testing.T) {
|
||||
// Test with empty strings
|
||||
if CheckVersion("", "v1.0.0") {
|
||||
t.Error("Empty version should not be >= v1.0.0")
|
||||
}
|
||||
|
||||
// Test with malformed versions (should be handled gracefully)
|
||||
// Note: semver.Compare might panic or return unexpected results for invalid versions
|
||||
// but our function should handle the common cases
|
||||
tests := []struct {
|
||||
version string
|
||||
minimum string
|
||||
desc string
|
||||
}{
|
||||
{"v1.0.0/", "v1.0.0", "version with trailing slash"},
|
||||
{"v1.0.0/abc/def", "v1.0.0", "version with multiple slashes"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
// This should not panic
|
||||
result := CheckVersion(test.version, test.minimum)
|
||||
t.Logf("%s: CheckVersion(%q, %q) = %t", test.desc, test.version, test.minimum, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark version operations
|
||||
func BenchmarkVersion(b *testing.B) {
|
||||
// Reset the cached version to test actual computation
|
||||
v = ""
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Version()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkVersionInfo(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = VersionInfo()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCheckVersion(b *testing.B) {
|
||||
version := "v1.2.3/abc123"
|
||||
minimum := "v1.2.0"
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = CheckVersion(version, minimum)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCheckVersionDevSnapshot(b *testing.B) {
|
||||
version := "dev-snapshot"
|
||||
minimum := "v1.2.0"
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = CheckVersion(version, minimum)
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user