common/version/version.go

230 lines
7.1 KiB
Go

// 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 (
"fmt"
"log/slog"
"runtime"
"runtime/debug"
"strings"
"github.com/prometheus/client_golang/prometheus"
"github.com/spf13/cobra"
"golang.org/x/mod/semver"
)
// 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 // 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"` // 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() {
info.BuildTime = buildTime
info.GitRev = gitVersion
if len(VERSION) == 0 {
VERSION = "dev-snapshot"
} else {
if !strings.HasPrefix(VERSION, "v") {
VERSION = "v" + VERSION
}
if !semver.IsValid(VERSION) {
slog.Default().Warn("invalid version number", "version", VERSION)
}
}
if bi, ok := debug.ReadBuildInfo(); ok {
for _, h := range bi.Settings {
// logger.Setup().Info("build info", "k", h.Key, "v", h.Value)
switch h.Key {
case "vcs.time":
if len(buildTime) == 0 {
buildTime = h.Value
}
info.BuildTime = h.Value
case "vcs.revision":
// https://blog.carlmjohnson.net/post/2023/golang-git-hash-how-to/
// todo: use BuildInfo.Main.Version if revision is empty
info.GitRev = h.Value
// if gitVersion != h.Value {
// logger.Setup().Warn("gitVersion and info.GitRev differs", "gitVersion", gitVersion, "gitRev", info.GitRev)
// }
gitVersion = h.Value
case "vcs.modified":
if h.Value == "true" {
gitModified = true
}
}
}
}
info.GitRevShort = info.GitRev
if len(info.GitRevShort) > 7 {
info.GitRevShort = info.GitRevShort[:7]
}
info.Version = 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 {
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)
},
}
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:"-"` // 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, "-", "_")
name = name + "_build_info"
} else {
name = "build_info"
}
buildInfo := prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: name,
Help: "Build information including version, build time, and git revision",
},
[]string{
"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)
info := VersionInfo()
buildInfo.WithLabelValues(
fmt.Sprintf("%s/%s",
info.Version, info.GitRevShort,
),
buildTime,
info.BuildTime,
info.GitRev,
).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
}
extra := []string{}
if len(buildTime) > 0 {
extra = append(extra, buildTime)
}
extra = append(extra, runtime.Version())
v := VERSION
if len(gitVersion) > 0 {
g := gitVersion
if len(g) > 7 {
g = g[0:7]
}
v += "/" + g
if gitModified {
v += "-M"
}
}
v = fmt.Sprintf("%s (%s)", v, strings.Join(extra, ", "))
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]
}
if semver.Compare(version, minimumVersion) < 0 {
// log.Debug("version too old", "v", cl.Version.Version)
return false
}
return true
}