From 09b52f92d75a6f07484a278b2d33adbb5ceec4a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ask=20Bj=C3=B8rn=20Hansen?= Date: Fri, 6 Jun 2025 20:19:08 -0700 Subject: [PATCH] version: add documentation and tests --- version/version.go | 80 +++++++++-- version/version_test.go | 311 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 376 insertions(+), 15 deletions(-) create mode 100644 version/version_test.go diff --git a/version/version.go b/version/version.go index 0d07a55..409fcaa 100644 --- a/version/version.go +++ b/version/version.go @@ -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] } diff --git a/version/version_test.go b/version/version_test.go new file mode 100644 index 0000000..651168b --- /dev/null +++ b/version/version_test.go @@ -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) + } +}