mirror of
https://github.com/juanfont/headscale.git
synced 2026-02-25 17:14:56 +01:00
Use GORM AutoMigrate instead of raw SQL to create the database_versions table, since PostgreSQL does not support the datetime type used in the raw SQL (it requires timestamp).
252 lines
7.0 KiB
Go
252 lines
7.0 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
|
|
}
|
|
|
|
// 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 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
|
|
}
|
|
|
|
// 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,
|
|
)
|
|
}
|
|
}
|