Files
headscale/hscontrol/db/versioncheck.go
Kristoffer Dalby 82958835ce db: enforce strict version upgrade path
Add a version check that runs before database migrations to ensure
users do not skip minor versions or downgrade. This protects database
migrations and allows future cleanup of old migration code.

Rules enforced:
- Same minor version: always allowed (patch changes either way)
- Single minor upgrade (e.g. 0.27 -> 0.28): allowed
- Multi-minor upgrade (e.g. 0.25 -> 0.28): blocked with guidance
- Any minor downgrade: blocked
- Major version change: blocked
- Dev builds: warn but allow, preserve stored version

The version is stored in a purpose-built database_versions table
after migrations succeed. The table is created with raw SQL before
gormigrate runs to avoid circular dependencies.

Updates #3058
2026-02-19 08:21:23 +01:00

257 lines
7.1 KiB
Go

package db
import (
"errors"
"fmt"
"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
}
const createDatabaseVersionsSQL = `CREATE TABLE IF NOT EXISTS database_versions(
id integer PRIMARY KEY,
version text NOT NULL,
updated_at datetime
)`
// 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 raw SQL to match schema.sql exactly.
// This runs before gormigrate migrations.
func ensureDatabaseVersionTable(db *gorm.DB) error {
err := db.Exec(createDatabaseVersionsSQL).Error
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
}
// isDev reports whether a version string represents a development build
// that should skip version checking.
func isDev(version string) bool {
return version == "" || version == "dev" || version == "(devel)"
}
// 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,
)
}
}