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
This commit is contained in:
parent
4483fd0cad
commit
77ba225cdb
@ -3,6 +3,7 @@ package db
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -139,10 +140,49 @@ func setDatabaseVersion(db *gorm.DB, version string) error {
|
|||||||
return nil
|
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
|
// isDev reports whether a version string represents a development build
|
||||||
// that should skip version checking.
|
// 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 {
|
func isDev(version string) bool {
|
||||||
return version == "" || version == "dev" || version == "(devel)"
|
if version == "" || version == "dev" || version == "(devel)" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := pseudoVersionTime(version)
|
||||||
|
|
||||||
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkVersionUpgradePath verifies that the running headscale version
|
// checkVersionUpgradePath verifies that the running headscale version
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package db
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@ -56,12 +57,206 @@ func TestSemverString(t *testing.T) {
|
|||||||
assert.Equal(t, "v0.28.3", s.String())
|
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) {
|
func TestIsDev(t *testing.T) {
|
||||||
assert.True(t, isDev(""))
|
tests := []struct {
|
||||||
assert.True(t, isDev("dev"))
|
name string
|
||||||
assert.True(t, isDev("(devel)"))
|
input string
|
||||||
assert.False(t, isDev("v0.28.0"))
|
want bool
|
||||||
assert.False(t, isDev("0.28.0"))
|
}{
|
||||||
|
// 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
|
// versionTestDB creates an in-memory SQLite database with the
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user