mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-15 20:40:55 +09:00
77ba225cdb
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
514 lines
13 KiB
Go
514 lines
13 KiB
Go
package db
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/glebarez/sqlite"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func TestParseVersion(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want semver
|
|
wantErr bool
|
|
}{
|
|
{input: "v0.25.0", want: semver{0, 25, 0}},
|
|
{input: "0.25.0", want: semver{0, 25, 0}},
|
|
{input: "v0.25.1", want: semver{0, 25, 1}},
|
|
{input: "v1.0.0", want: semver{1, 0, 0}},
|
|
{input: "v0.28.3", want: semver{0, 28, 3}},
|
|
// Pre-release suffixes stripped
|
|
{input: "v0.25.0-beta.1", want: semver{0, 25, 0}},
|
|
{input: "v0.25.0-rc1", want: semver{0, 25, 0}},
|
|
// Build metadata stripped
|
|
{input: "v0.25.0+build123", want: semver{0, 25, 0}},
|
|
{input: "v0.25.0-beta.1+build123", want: semver{0, 25, 0}},
|
|
// Invalid inputs
|
|
{input: "", wantErr: true},
|
|
{input: "dev", wantErr: true},
|
|
{input: "vfoo.bar.baz", wantErr: true},
|
|
{input: "v1.2", wantErr: true},
|
|
{input: "v1", wantErr: true},
|
|
{input: "not-a-version", wantErr: true},
|
|
{input: "v1.2.3.4", wantErr: true},
|
|
{input: "(devel)", wantErr: true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
got, err := parseVersion(tt.input)
|
|
if tt.wantErr {
|
|
assert.Error(t, err)
|
|
} else {
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSemverString(t *testing.T) {
|
|
s := semver{0, 28, 3}
|
|
assert.Equal(t, "v0.28.3", s.String())
|
|
}
|
|
|
|
func TestPseudoVersionTime(t *testing.T) {
|
|
parseTS := func(s string) time.Time {
|
|
t.Helper()
|
|
|
|
ts, err := time.Parse(pseudoVersionTimeLayout, s)
|
|
require.NoError(t, err)
|
|
|
|
return ts
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantOK bool
|
|
wantTime time.Time
|
|
}{
|
|
// Accept: all three Go pseudo-version shapes.
|
|
{
|
|
name: "no ancestor tag (v0.0.0 base)",
|
|
input: "v0.0.0-20260522092201-58a85b68b3d9",
|
|
wantOK: true,
|
|
wantTime: parseTS("20260522092201"),
|
|
},
|
|
{
|
|
name: "ancestor is pre-release tag",
|
|
input: "v0.29.0-beta.1.0.20260522092201-58a85b68b3d9",
|
|
wantOK: true,
|
|
wantTime: parseTS("20260522092201"),
|
|
},
|
|
{
|
|
name: "ancestor is release tag",
|
|
input: "v0.29.1-0.20260522092201-58a85b68b3d9",
|
|
wantOK: true,
|
|
wantTime: parseTS("20260522092201"),
|
|
},
|
|
{
|
|
name: "earliest realistic Go module date",
|
|
input: "v0.0.0-20180101000000-000000000000",
|
|
wantOK: true,
|
|
wantTime: parseTS("20180101000000"),
|
|
},
|
|
|
|
// Reject: real release tags must not look like pseudo-versions.
|
|
{name: "release tag", input: "v0.29.0"},
|
|
{name: "pre-release tag", input: "v0.29.0-beta.1"},
|
|
{name: "rc tag", input: "v0.29.0-rc1"},
|
|
{name: "tag with build metadata", input: "v0.29.0+build123"},
|
|
|
|
// Reject: literals handled elsewhere.
|
|
{name: "empty", input: ""},
|
|
{name: "dev literal", input: "dev"},
|
|
{name: "devel literal", input: "(devel)"},
|
|
|
|
// Reject: malformed hash.
|
|
{name: "hash too short", input: "v0.0.0-20260522092201-58a85b6"},
|
|
{name: "hash too long", input: "v0.0.0-20260522092201-58a85b68b3d9aa"},
|
|
{name: "hash uppercase hex", input: "v0.0.0-20260522092201-58A85B68B3D9"},
|
|
{name: "hash non-hex", input: "v0.0.0-20260522092201-zzzzzzzzzzzz"},
|
|
|
|
// Reject: malformed timestamp.
|
|
{name: "timestamp too short", input: "v0.0.0-2026052209220-58a85b68b3d9"},
|
|
{name: "timestamp too long", input: "v0.0.0-202605220922010-58a85b68b3d9"},
|
|
{name: "invalid month", input: "v0.0.0-20261322092201-58a85b68b3d9"},
|
|
{name: "invalid day", input: "v0.0.0-20260230092201-58a85b68b3d9"},
|
|
{name: "invalid hour", input: "v0.0.0-20260522252201-58a85b68b3d9"},
|
|
{name: "invalid minute", input: "v0.0.0-20260522096001-58a85b68b3d9"},
|
|
{name: "invalid second", input: "v0.0.0-20260522092260-58a85b68b3d9"},
|
|
{name: "leap day on non-leap year", input: "v0.0.0-20230229000000-58a85b68b3d9"},
|
|
|
|
// Reject: missing components.
|
|
{name: "missing date and hash", input: "v0.0.0-"},
|
|
{name: "missing hash", input: "v0.0.0-20260522092201-"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, ok := pseudoVersionTime(tt.input)
|
|
assert.Equal(t, tt.wantOK, ok)
|
|
|
|
if tt.wantOK {
|
|
assert.True(t, got.Equal(tt.wantTime),
|
|
"want %s, got %s", tt.wantTime, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsDev(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want bool
|
|
}{
|
|
// Existing literals.
|
|
{name: "empty", input: "", want: true},
|
|
{name: "dev", input: "dev", want: true},
|
|
{name: "devel", input: "(devel)", want: true},
|
|
{name: "release tag", input: "v0.28.0", want: false},
|
|
{name: "release tag no v", input: "0.28.0", want: false},
|
|
{name: "pre-release tag", input: "v0.29.0-beta.1", want: false},
|
|
|
|
// Go module pseudo-versions — all three shapes Go emits per
|
|
// golang.org/ref/mod#pseudo-versions. Untagged commits
|
|
// (such as main-sha docker builds) must be treated as dev
|
|
// so they neither poison database_versions nor trip the
|
|
// upgrade-path guard.
|
|
{
|
|
name: "pseudo v0.0.0 base",
|
|
input: "v0.0.0-20260522092201-58a85b68b3d9",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "pseudo from pre-release ancestor",
|
|
input: "v0.29.0-beta.1.0.20260522092201-58a85b68b3d9",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "pseudo from release ancestor",
|
|
input: "v0.29.1-0.20260522092201-58a85b68b3d9",
|
|
want: true,
|
|
},
|
|
|
|
// Malformed pseudo-version lookalikes must NOT be treated
|
|
// as dev — they fall through to the semver parser.
|
|
{
|
|
name: "malformed timestamp not dev",
|
|
input: "v0.0.0-20261322092201-58a85b68b3d9",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "hash wrong length not dev",
|
|
input: "v0.0.0-20260522092201-58a85b6",
|
|
want: false,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assert.Equal(t, tt.want, isDev(tt.input))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCheckVersionUpgradePath_StoredPseudoVersion exercises the
|
|
// upgrade path when database_versions holds a Go module pseudo-version
|
|
// written by an untagged main-sha build. Without dev handling, the
|
|
// stored pseudo-version parses as v0.0.0 and the next real release
|
|
// trips the multi-minor guard.
|
|
func TestCheckVersionUpgradePath_StoredPseudoVersion(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
stored string
|
|
currentVersion string
|
|
}{
|
|
{
|
|
name: "v0.0.0 base pseudo to real release",
|
|
stored: "v0.0.0-20260520093041-e4e742c776ee",
|
|
currentVersion: "v0.29.0-beta.1",
|
|
},
|
|
{
|
|
name: "pseudo from pre-release ancestor",
|
|
stored: "v0.29.0-beta.1.0.20260520093041-e4e742c776ee",
|
|
currentVersion: "v0.29.0",
|
|
},
|
|
{
|
|
name: "pseudo from release ancestor",
|
|
stored: "v0.28.1-0.20260520093041-e4e742c776ee",
|
|
currentVersion: "v0.29.0",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
db := versionTestDB(t)
|
|
require.NoError(t, setDatabaseVersion(db, tt.stored))
|
|
err := checkVersionUpgradePathFromVersions(db, tt.currentVersion)
|
|
assert.NoError(t, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCheckVersionUpgradePath_CurrentPseudoDoesNotPoison locks the
|
|
// contract that a main-sha (pseudo-version) binary must preserve the
|
|
// stored real release so the next real release can upgrade cleanly.
|
|
// Mirrors the gating in db.go around setDatabaseVersion.
|
|
func TestCheckVersionUpgradePath_CurrentPseudoDoesNotPoison(t *testing.T) {
|
|
db := versionTestDB(t)
|
|
require.NoError(t, setDatabaseVersion(db, "v0.28.0"))
|
|
|
|
current := "v0.0.0-20260522092201-58a85b68b3d9"
|
|
err := checkVersionUpgradePathFromVersions(db, current)
|
|
require.NoError(t, err)
|
|
|
|
// Mirror db.go: only write the current version when !isDev.
|
|
if !isDev(current) {
|
|
require.NoError(t, setDatabaseVersion(db, current))
|
|
}
|
|
|
|
stored, err := getDatabaseVersion(db)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "v0.28.0", stored,
|
|
"pseudo-version run must not overwrite stored release")
|
|
}
|
|
|
|
// versionTestDB creates an in-memory SQLite database with the
|
|
// database_versions table already bootstrapped.
|
|
func versionTestDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
|
|
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
|
|
err = ensureDatabaseVersionTable(db)
|
|
require.NoError(t, err)
|
|
|
|
return db
|
|
}
|
|
|
|
func TestSetAndGetDatabaseVersion(t *testing.T) {
|
|
db := versionTestDB(t)
|
|
|
|
// Initially empty
|
|
v, err := getDatabaseVersion(db)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, v)
|
|
|
|
// Set a version
|
|
err = setDatabaseVersion(db, "v0.27.0")
|
|
require.NoError(t, err)
|
|
|
|
v, err = getDatabaseVersion(db)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "v0.27.0", v)
|
|
|
|
// Update the version (upsert)
|
|
err = setDatabaseVersion(db, "v0.28.0")
|
|
require.NoError(t, err)
|
|
|
|
v, err = getDatabaseVersion(db)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "v0.28.0", v)
|
|
}
|
|
|
|
func TestEnsureDatabaseVersionTableIdempotent(t *testing.T) {
|
|
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
|
|
// Call twice — should not error
|
|
err = ensureDatabaseVersionTable(db)
|
|
require.NoError(t, err)
|
|
|
|
err = ensureDatabaseVersionTable(db)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// TestCheckVersionUpgradePathDirect tests the version comparison logic
|
|
// by directly seeding the database, bypassing types.GetVersionInfo()
|
|
// (which returns "dev" in test environments and cannot be overridden).
|
|
func TestCheckVersionUpgradePathDirect(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
storedVersion string // empty means no row stored
|
|
currentVersion string
|
|
wantErr bool
|
|
errContains string
|
|
}{
|
|
// Fresh database (no stored version)
|
|
{
|
|
name: "fresh db allows any version",
|
|
storedVersion: "",
|
|
currentVersion: "v0.28.0",
|
|
},
|
|
|
|
// Stored is dev
|
|
{
|
|
name: "real version over dev db",
|
|
storedVersion: "dev",
|
|
currentVersion: "v0.28.0",
|
|
},
|
|
{
|
|
name: "devel version in db",
|
|
storedVersion: "(devel)",
|
|
currentVersion: "v0.28.0",
|
|
},
|
|
|
|
// Same version
|
|
{
|
|
name: "same version",
|
|
storedVersion: "v0.27.0",
|
|
currentVersion: "v0.27.0",
|
|
},
|
|
|
|
// Patch changes within same minor
|
|
{
|
|
name: "patch upgrade",
|
|
storedVersion: "v0.27.0",
|
|
currentVersion: "v0.27.3",
|
|
},
|
|
{
|
|
name: "patch downgrade within same minor",
|
|
storedVersion: "v0.27.3",
|
|
currentVersion: "v0.27.0",
|
|
},
|
|
|
|
// Single minor upgrade
|
|
{
|
|
name: "single minor upgrade",
|
|
storedVersion: "v0.27.0",
|
|
currentVersion: "v0.28.0",
|
|
},
|
|
{
|
|
name: "single minor upgrade with different patches",
|
|
storedVersion: "v0.27.3",
|
|
currentVersion: "v0.28.1",
|
|
},
|
|
|
|
// Multi-minor upgrade (blocked)
|
|
{
|
|
name: "two minor versions ahead",
|
|
storedVersion: "v0.25.0",
|
|
currentVersion: "v0.27.0",
|
|
wantErr: true,
|
|
errContains: "latest v0.26.x",
|
|
},
|
|
{
|
|
name: "three minor versions ahead",
|
|
storedVersion: "v0.25.0",
|
|
currentVersion: "v0.28.0",
|
|
wantErr: true,
|
|
errContains: "latest v0.26.x",
|
|
},
|
|
|
|
// Minor downgrades (blocked)
|
|
{
|
|
name: "single minor downgrade",
|
|
storedVersion: "v0.28.0",
|
|
currentVersion: "v0.27.0",
|
|
wantErr: true,
|
|
errContains: "downgrading",
|
|
},
|
|
{
|
|
name: "multi minor downgrade",
|
|
storedVersion: "v0.28.0",
|
|
currentVersion: "v0.25.0",
|
|
wantErr: true,
|
|
errContains: "downgrading",
|
|
},
|
|
|
|
// Major version mismatch
|
|
{
|
|
name: "major version upgrade",
|
|
storedVersion: "v0.28.0",
|
|
currentVersion: "v1.0.0",
|
|
wantErr: true,
|
|
errContains: "major version",
|
|
},
|
|
{
|
|
name: "major version downgrade",
|
|
storedVersion: "v1.0.0",
|
|
currentVersion: "v0.28.0",
|
|
wantErr: true,
|
|
errContains: "major version",
|
|
},
|
|
|
|
// Pre-release versions
|
|
{
|
|
name: "pre-release single minor upgrade",
|
|
storedVersion: "v0.27.0",
|
|
currentVersion: "v0.28.0-beta.1",
|
|
},
|
|
{
|
|
name: "pre-release multi minor upgrade blocked",
|
|
storedVersion: "v0.25.0",
|
|
currentVersion: "v0.27.0-rc1",
|
|
wantErr: true,
|
|
errContains: "latest v0.26.x",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
db := versionTestDB(t)
|
|
|
|
// Seed the stored version if provided
|
|
if tt.storedVersion != "" {
|
|
err := setDatabaseVersion(db, tt.storedVersion)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
err := checkVersionUpgradePathFromVersions(db, tt.currentVersion)
|
|
if tt.wantErr {
|
|
require.Error(t, err)
|
|
|
|
if tt.errContains != "" {
|
|
assert.Contains(t, err.Error(), tt.errContains)
|
|
}
|
|
} else {
|
|
assert.NoError(t, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// checkVersionUpgradePathFromVersions is a test helper that runs the
|
|
// version comparison logic with a specific currentVersion string,
|
|
// bypassing types.GetVersionInfo(). It replicates the logic from
|
|
// checkVersionUpgradePath but accepts the version as a parameter.
|
|
func checkVersionUpgradePathFromVersions(db *gorm.DB, currentVersion string) error {
|
|
if isDev(currentVersion) {
|
|
return nil
|
|
}
|
|
|
|
storedVersion, err := getDatabaseVersion(db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if storedVersion == "" {
|
|
return nil
|
|
}
|
|
|
|
if isDev(storedVersion) {
|
|
return nil
|
|
}
|
|
|
|
current, err := parseVersion(currentVersion)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
stored, err := parseVersion(storedVersion)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if current.Major != stored.Major {
|
|
return errVersionMajorChange
|
|
}
|
|
|
|
minorDiff := current.Minor - stored.Minor
|
|
|
|
switch {
|
|
case minorDiff == 0:
|
|
return nil
|
|
case minorDiff == 1:
|
|
return nil
|
|
case minorDiff > 1:
|
|
return fmt.Errorf(
|
|
"please upgrade to the latest v%d.%d.x release first: %w",
|
|
stored.Major, stored.Minor+1,
|
|
errVersionUpgrade,
|
|
)
|
|
default:
|
|
return fmt.Errorf("downgrading: %w", errVersionDowngrade)
|
|
}
|
|
}
|