version: add documentation and tests
This commit is contained in:
parent
785abdec8d
commit
09b52f92d7
@ -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
|
package version
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -12,21 +26,25 @@ import (
|
|||||||
"golang.org/x/mod/semver"
|
"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 (
|
var (
|
||||||
VERSION string
|
VERSION string // Semantic version (e.g., "1.0.0" or "v1.0.0")
|
||||||
buildTime string
|
buildTime string // Build timestamp (RFC3339 format)
|
||||||
gitVersion string
|
gitVersion string // Git commit hash
|
||||||
gitModified bool
|
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
|
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 {
|
type Info struct {
|
||||||
Version string `json:",omitempty"`
|
Version string `json:",omitempty"` // Semantic version with "v" prefix
|
||||||
GitRev string `json:",omitempty"`
|
GitRev string `json:",omitempty"` // Full Git commit hash
|
||||||
GitRevShort string `json:",omitempty"`
|
GitRevShort string `json:",omitempty"` // Shortened Git commit hash (7 characters)
|
||||||
BuildTime string `json:",omitempty"`
|
BuildTime string `json:",omitempty"` // Build timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@ -79,10 +97,16 @@ func init() {
|
|||||||
Version()
|
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 {
|
func VersionCmd(name string) *cobra.Command {
|
||||||
versionCmd := &cobra.Command{
|
versionCmd := &cobra.Command{
|
||||||
Use: "version",
|
Use: "version",
|
||||||
Short: "Print version and build information",
|
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) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
ver := Version()
|
ver := Version()
|
||||||
fmt.Printf("%s %s\n", name, ver)
|
fmt.Printf("%s %s\n", name, ver)
|
||||||
@ -91,15 +115,23 @@ func VersionCmd(name string) *cobra.Command {
|
|||||||
return versionCmd
|
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 {
|
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 {
|
func (cmd *KongVersionCmd) Run() error {
|
||||||
fmt.Printf("%s %s\n", cmd.Name, Version())
|
fmt.Printf("%s %s\n", cmd.Name, Version())
|
||||||
return nil
|
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) {
|
func RegisterMetric(name string, registry prometheus.Registerer) {
|
||||||
if len(name) > 0 {
|
if len(name) > 0 {
|
||||||
name = strings.ReplaceAll(name, "-", "_")
|
name = strings.ReplaceAll(name, "-", "_")
|
||||||
@ -110,13 +142,13 @@ func RegisterMetric(name string, registry prometheus.Registerer) {
|
|||||||
buildInfo := prometheus.NewGaugeVec(
|
buildInfo := prometheus.NewGaugeVec(
|
||||||
prometheus.GaugeOpts{
|
prometheus.GaugeOpts{
|
||||||
Name: name,
|
Name: name,
|
||||||
Help: "Build information",
|
Help: "Build information including version, build time, and git revision",
|
||||||
},
|
},
|
||||||
[]string{
|
[]string{
|
||||||
"version",
|
"version", // Combined version/git format (e.g., "v1.0.0/abc123")
|
||||||
"buildtime",
|
"buildtime", // Build timestamp from ldflags
|
||||||
"gittime",
|
"gittime", // Git commit timestamp from VCS info
|
||||||
"git",
|
"git", // Full Git commit hash
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
registry.MustRegister(buildInfo)
|
registry.MustRegister(buildInfo)
|
||||||
@ -131,12 +163,20 @@ func RegisterMetric(name string, registry prometheus.Registerer) {
|
|||||||
).Set(1)
|
).Set(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v caches the formatted version string to avoid repeated computation.
|
||||||
var v string
|
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 {
|
func VersionInfo() Info {
|
||||||
return 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 {
|
func Version() string {
|
||||||
if len(v) > 0 {
|
if len(v) > 0 {
|
||||||
return v
|
return v
|
||||||
@ -164,10 +204,20 @@ func Version() string {
|
|||||||
return v
|
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 {
|
func CheckVersion(version, minimumVersion string) bool {
|
||||||
if version == "dev-snapshot" {
|
if version == "dev-snapshot" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
// Strip Git hash suffix if present (e.g., "v1.0.0/abc123" -> "v1.0.0")
|
||||||
if idx := strings.Index(version, "/"); idx >= 0 {
|
if idx := strings.Index(version, "/"); idx >= 0 {
|
||||||
version = version[0:idx]
|
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