Files
headscale/hscontrol/db/versioncheck.go
T
Kristoffer Dalby 77ba225cdb db: treat Go module pseudo-versions as dev builds
Untagged main-sha builds inherit a Go module pseudo-version from
runtime/debug.BuildInfo (vX.Y.Z-<timestamp>-<hash>). isDev only
filtered "", "dev", and "(devel)", so the pseudo-version was stored
in database_versions and the next real release tripped the
multi-minor upgrade guard:

    headscale version v0.29.0-beta.1 cannot be used with a database
    last used by v0.0.0-20260520093041-e4e742c776ee, upgrading more
    than one minor version at a time is not supported

Add pseudoVersionTime, a regex + time.Parse predicate covering all
three Go pseudo-version forms (v0.0.0 base, pre-release ancestor,
release ancestor), and delegate from isDev. The dev gate at
db.go:790 already prevents pseudo-versions from being written, so
already-poisoned databases self-heal on the next real-release start.

Fixes #3281
2026-05-26 17:29:13 +02:00

292 lines
8.5 KiB
Go

package db
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
)
var errVersionUpgrade = errors.New("version upgrade not supported")
var errVersionDowngrade = errors.New("version downgrade not supported")
var errVersionMajorChange = errors.New("major version change not supported")
var errVersionParse = errors.New("cannot parse version")
var errVersionFormat = errors.New(
"version does not follow semver major.minor.patch format",
)
// DatabaseVersion tracks the headscale version that last
// successfully started against this database.
// It is a single-row table (ID is always 1).
type DatabaseVersion struct {
ID uint `gorm:"primaryKey"`
Version string `gorm:"not null"`
UpdatedAt time.Time
}
// semver holds parsed major.minor.patch components.
type semver struct {
Major int
Minor int
Patch int
}
func (s semver) String() string {
return fmt.Sprintf("v%d.%d.%d", s.Major, s.Minor, s.Patch)
}
// parseVersion parses a version string like "v0.25.0", "0.25.1",
// "v0.25.0-beta.1", or "v0.25.0-rc1+build123" into its major, minor,
// patch components. Pre-release and build metadata suffixes are stripped.
func parseVersion(s string) (semver, error) {
if s == "" || s == "dev" {
return semver{}, fmt.Errorf("%q: %w", s, errVersionParse)
}
v := strings.TrimPrefix(s, "v")
// Strip pre-release suffix (everything after first '-')
// and build metadata (everything after first '+').
if idx := strings.IndexAny(v, "-+"); idx != -1 {
v = v[:idx]
}
parts := strings.Split(v, ".")
if len(parts) != 3 {
return semver{}, fmt.Errorf("%q: %w", s, errVersionFormat)
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return semver{}, fmt.Errorf("invalid major version in %q: %w", s, err)
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return semver{}, fmt.Errorf("invalid minor version in %q: %w", s, err)
}
patch, err := strconv.Atoi(parts[2])
if err != nil {
return semver{}, fmt.Errorf("invalid patch version in %q: %w", s, err)
}
return semver{Major: major, Minor: minor, Patch: patch}, nil
}
// ensureDatabaseVersionTable creates the database_versions table if it
// does not already exist. Uses [gorm.DB.AutoMigrate] to handle dialect
// differences between SQLite (datetime) and PostgreSQL (timestamp).
// This runs before gormigrate migrations.
func ensureDatabaseVersionTable(db *gorm.DB) error {
err := db.AutoMigrate(&DatabaseVersion{})
if err != nil {
return fmt.Errorf("creating database version table: %w", err)
}
return nil
}
// getDatabaseVersion reads the stored version from the database.
// Returns an empty string if no version has been stored yet.
func getDatabaseVersion(db *gorm.DB) (string, error) {
var version string
result := db.Raw("SELECT version FROM database_versions WHERE id = 1").Scan(&version)
if result.Error != nil {
return "", fmt.Errorf("reading database version: %w", result.Error)
}
if result.RowsAffected == 0 {
return "", nil
}
return version, nil
}
// setDatabaseVersion upserts the version row in the database.
func setDatabaseVersion(db *gorm.DB, version string) error {
now := time.Now().UTC()
// Try update first, then insert if no rows affected.
result := db.Exec(
"UPDATE database_versions SET version = ?, updated_at = ? WHERE id = 1",
version, now,
)
if result.Error != nil {
return fmt.Errorf("updating database version: %w", result.Error)
}
if result.RowsAffected == 0 {
err := db.Exec(
"INSERT INTO database_versions (id, version, updated_at) VALUES (1, ?, ?)",
version, now,
).Error
if err != nil {
return fmt.Errorf("inserting database version: %w", err)
}
}
return nil
}
// pseudoVersionTimeLayout is Go's pseudo-version timestamp layout
// (golang.org/ref/mod#pseudo-versions): UTC yyyymmddhhmmss.
const pseudoVersionTimeLayout = "20060102150405"
// pseudoVersionSuffix matches the trailing "<sep><14 digits>-<12
// lowercase hex>" of a Go module pseudo-version. The base form
// (vX.0.0-<date>-<hash>) uses "-" before the timestamp; the
// pre-release-ancestor and release-ancestor forms
// (vX.Y.Z-pre.0.<date>-<hash> and vX.Y.(Z+1)-0.<date>-<hash>) use "."
// because the digit-only "0" marker precedes the timestamp.
var pseudoVersionSuffix = regexp.MustCompile(`[-.](\d{14})-[0-9a-f]{12}$`)
// pseudoVersionTime returns the embedded commit time when v is a
// syntactically and semantically valid Go module pseudo-version. The
// timestamp must parse as a real UTC time; lookalikes with malformed
// dates (e.g. month 13, day 30 in February) are rejected.
func pseudoVersionTime(v string) (time.Time, bool) {
m := pseudoVersionSuffix.FindStringSubmatch(v)
if m == nil {
return time.Time{}, false
}
t, err := time.Parse(pseudoVersionTimeLayout, m[1])
if err != nil {
return time.Time{}, false
}
return t, true
}
// isDev reports whether a version string represents a development build
// that should skip version checking. Go module pseudo-versions (used by
// untagged main-sha builds, where runtime/debug.BuildInfo falls back to
// vX.Y.Z-<timestamp>-<commit>) are treated as dev to avoid poisoning
// database_versions with synthetic baselines.
func isDev(version string) bool {
if version == "" || version == "dev" || version == "(devel)" {
return true
}
_, ok := pseudoVersionTime(version)
return ok
}
// checkVersionUpgradePath verifies that the running headscale version
// is compatible with the version that last used this database.
//
// Rules:
// - If the running binary has no version ("dev" or empty), warn and skip.
// - If no version is stored in the database, allow (first run with this feature).
// - If the stored version is "dev", allow (previous run was unversioned).
// - Same minor version: always allowed (patch changes in either direction).
// - Single minor version upgrade (stored.minor+1 == current.minor): allowed.
// - Multi-minor upgrade or any minor downgrade: blocked with a fatal error.
func checkVersionUpgradePath(db *gorm.DB) error {
err := ensureDatabaseVersionTable(db)
if err != nil {
return err
}
currentVersion := types.GetVersionInfo().Version
// Running binary has no real version — skip the check but
// preserve whatever version is already stored.
if isDev(currentVersion) {
storedVersion, err := getDatabaseVersion(db)
if err != nil {
return err
}
if storedVersion != "" && !isDev(storedVersion) {
log.Warn().
Str("database_version", storedVersion).
Msg("running a development build of headscale without a version number, " +
"database version check is skipped, the stored database version is preserved")
}
return nil
}
storedVersion, err := getDatabaseVersion(db)
if err != nil {
return err
}
// No stored version — first run with this feature. Allow startup;
// the version will be stored after migrations succeed.
if storedVersion == "" {
return nil
}
// Previous run was an unversioned build — no meaningful comparison.
if isDev(storedVersion) {
return nil
}
current, err := parseVersion(currentVersion)
if err != nil {
return fmt.Errorf("parsing current version: %w", err)
}
stored, err := parseVersion(storedVersion)
if err != nil {
return fmt.Errorf("parsing stored database version: %w", err)
}
if current.Major != stored.Major {
return fmt.Errorf(
"headscale version %s cannot be used with a database last used by %s: %w",
currentVersion, storedVersion, errVersionMajorChange,
)
}
minorDiff := current.Minor - stored.Minor
switch {
case minorDiff == 0:
// Same minor version — patch changes are always fine.
return nil
case minorDiff == 1:
// Single minor version upgrade — allowed.
return nil
case minorDiff > 1:
// Multi-minor upgrade — blocked.
return fmt.Errorf(
"headscale version %s cannot be used with a database last used by %s, "+
"upgrading more than one minor version at a time is not supported, "+
"please upgrade to the latest v%d.%d.x release first, then to %s, "+
"release page: https://github.com/juanfont/headscale/releases: %w",
currentVersion, storedVersion,
stored.Major, stored.Minor+1,
current.String(),
errVersionUpgrade,
)
default:
// minorDiff < 0 — any minor downgrade is blocked.
return fmt.Errorf(
"headscale version %s cannot be used with a database last used by %s, "+
"downgrading to a previous minor version is not supported, "+
"release page: https://github.com/juanfont/headscale/releases: %w",
currentVersion, storedVersion,
errVersionDowngrade,
)
}
}