Added tentacle updater.
This commit is contained in:
1 parent
f2cc47c8a0
commit
7591f65b58
17 files changed
+948
-22
No files matched your search
@@ -24,4 +24,5 @@ type Snapshot struct {
|
||||
Message string
|
||||
Suppressed bool
|
||||
Repositories []Repository
|
||||
Updates []AvailableUpdate
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package tentacle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AvailableUpdate describes a newer repository release for an installed tentacle.
|
||||
type AvailableUpdate struct {
|
||||
ID string
|
||||
Name string
|
||||
InstalledVersion string
|
||||
AvailableVersion string
|
||||
RepositoryURL string
|
||||
RepositoryID string
|
||||
LocalID string
|
||||
}
|
||||
|
||||
// packageVersion accepts dotted numeric releases (including existing short
|
||||
// versions such as 0.1), with optional SemVer prerelease and build identifiers.
|
||||
type packageVersion struct {
|
||||
numbers []string
|
||||
prerelease []string
|
||||
}
|
||||
|
||||
func numeric_identifier(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, ch := range value {
|
||||
if ch < '0' || ch > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func version_identifiers(value string, prerelease bool) ([]string, error) {
|
||||
identifiers := strings.Split(value, ".")
|
||||
for _, identifier := range identifiers {
|
||||
if identifier == "" {
|
||||
return nil, fmt.Errorf("empty version identifier")
|
||||
}
|
||||
for _, ch := range identifier {
|
||||
if !(ch >= '0' && ch <= '9' || ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch == '-') {
|
||||
return nil, fmt.Errorf("invalid version identifier %q", identifier)
|
||||
}
|
||||
}
|
||||
if prerelease && numeric_identifier(identifier) && len(identifier) > 1 && identifier[0] == '0' {
|
||||
return nil, fmt.Errorf("prerelease number has a leading zero")
|
||||
}
|
||||
}
|
||||
return identifiers, nil
|
||||
}
|
||||
|
||||
func parse_package_version(value string) (packageVersion, error) {
|
||||
var result packageVersion
|
||||
core := strings.TrimPrefix(strings.TrimSpace(value), "v")
|
||||
if before, build, found := strings.Cut(core, "+"); found {
|
||||
if _, err := version_identifiers(build, false); err != nil {
|
||||
return result, err
|
||||
}
|
||||
core = before
|
||||
}
|
||||
if before, prerelease, found := strings.Cut(core, "-"); found {
|
||||
var err error
|
||||
result.prerelease, err = version_identifiers(prerelease, true)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
core = before
|
||||
}
|
||||
result.numbers = strings.Split(core, ".")
|
||||
for _, number := range result.numbers {
|
||||
if !numeric_identifier(number) {
|
||||
return result, fmt.Errorf("invalid numeric version %q", value)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Compare decimal strings without an integer-size limit.
|
||||
func compare_version_number(first, second string) int {
|
||||
first, second = strings.TrimLeft(first, "0"), strings.TrimLeft(second, "0")
|
||||
if len(first) < len(second) {
|
||||
return -1
|
||||
}
|
||||
if len(first) > len(second) {
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(first, second)
|
||||
}
|
||||
|
||||
// IsNewerVersion compares release and prerelease precedence. Build metadata
|
||||
// does not change precedence; missing numeric components are treated as zero.
|
||||
func IsNewerVersion(available, installed string) (bool, error) {
|
||||
first, err := parse_package_version(available)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
second, err := parse_package_version(installed)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for index := 0; index < len(first.numbers) || index < len(second.numbers); index++ {
|
||||
firstNumber, secondNumber := "0", "0"
|
||||
if index < len(first.numbers) {
|
||||
firstNumber = first.numbers[index]
|
||||
}
|
||||
if index < len(second.numbers) {
|
||||
secondNumber = second.numbers[index]
|
||||
}
|
||||
if compared := compare_version_number(firstNumber, secondNumber); compared != 0 {
|
||||
return compared > 0, nil
|
||||
}
|
||||
}
|
||||
if len(first.prerelease) == 0 || len(second.prerelease) == 0 {
|
||||
return len(first.prerelease) == 0 && len(second.prerelease) != 0, nil
|
||||
}
|
||||
for index := 0; index < len(first.prerelease) && index < len(second.prerelease); index++ {
|
||||
firstID, secondID := first.prerelease[index], second.prerelease[index]
|
||||
firstNumeric, secondNumeric := numeric_identifier(firstID), numeric_identifier(secondID)
|
||||
compared := strings.Compare(firstID, secondID)
|
||||
if firstNumeric && secondNumeric {
|
||||
compared = compare_version_number(firstID, secondID)
|
||||
}
|
||||
if firstNumeric != secondNumeric {
|
||||
return !firstNumeric, nil
|
||||
}
|
||||
if compared != 0 {
|
||||
return compared > 0, nil
|
||||
}
|
||||
}
|
||||
return len(first.prerelease) > len(second.prerelease), nil
|
||||
}
|
||||
|
||||
// AvailableUpdates finds newer versions of installed repository tentacles.
|
||||
func AvailableUpdates(snapshot Snapshot) []AvailableUpdate {
|
||||
var updates []AvailableUpdate
|
||||
for _, status := range snapshot.Entries {
|
||||
entry := status.Entry
|
||||
if entry.Repository == "" || entry.Version == "" {
|
||||
continue
|
||||
}
|
||||
for _, repository := range snapshot.Repositories {
|
||||
if repository.URL != entry.Repository || repository.ID != entry.RepositoryID {
|
||||
continue
|
||||
}
|
||||
for _, manifest := range repository.Tentacles {
|
||||
if manifest.ID != entry.LocalID {
|
||||
continue
|
||||
}
|
||||
newer, err := IsNewerVersion(manifest.Version, entry.Version)
|
||||
if err != nil || !newer {
|
||||
continue
|
||||
}
|
||||
updates = append(updates, AvailableUpdate{
|
||||
ID: entry.ID,
|
||||
Name: manifest.Name,
|
||||
InstalledVersion: entry.Version,
|
||||
AvailableVersion: manifest.Version,
|
||||
RepositoryURL: repository.URL,
|
||||
RepositoryID: repository.ID,
|
||||
LocalID: manifest.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return updates
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package tentacle
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestVersionPrecedence(t *testing.T) {
|
||||
ordered := []string{"1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-alpha.beta", "1.0.0-beta", "1.0.0-beta.2", "1.0.0-beta.11", "1.0.0-rc.1", "1.0.0", "1.1", "2.0"}
|
||||
for firstIndex, first := range ordered {
|
||||
for secondIndex, second := range ordered {
|
||||
newer, err := IsNewerVersion(first, second)
|
||||
if err != nil || newer != (firstIndex > secondIndex) {
|
||||
t.Fatalf("%s > %s: %v, %v", first, second, newer, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, pair := range [][2]string{{"v0.1", "0.1.0"}, {"1.0.0+build.2", "1.0.0+build.1"}, {"2026.11.01", "2026.11.1"}} {
|
||||
for _, versions := range [][2]string{pair, {pair[1], pair[0]}} {
|
||||
if newer, err := IsNewerVersion(versions[0], versions[1]); err != nil || newer {
|
||||
t.Fatalf("equal precedence: %v: %v %v", versions, newer, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if newer, err := IsNewerVersion("999999999999999999999999999", "2"); err != nil || !newer {
|
||||
t.Fatalf("large numeric component: %v %v", newer, err)
|
||||
}
|
||||
for _, invalid := range []string{"", "nightly", "1..0", "1.-2", "1.0-", "1.0-01", "1.0+", "1.0+foo+bar", "1.0-a_1"} {
|
||||
if _, err := IsNewerVersion(invalid, "1.0"); err == nil {
|
||||
t.Errorf("accepted invalid version %q", invalid)
|
||||
}
|
||||
if _, err := IsNewerVersion("2.0", invalid); err == nil {
|
||||
t.Errorf("accepted invalid installed version %q", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableUpdatesRequiresExactSource(t *testing.T) {
|
||||
repository := Repository{URL: "https://example.org/tentacles", ID: "example.source", Tentacles: []Manifest{{ID: "echo", Name: "Echo", Version: "1.0"}}}
|
||||
entry := Entry{Repository: repository.URL, RepositoryID: repository.ID, LocalID: "echo", Version: "1.0-rc.1"}
|
||||
entry.ID = PackageID(repository, entry.LocalID)
|
||||
snapshot := Snapshot{Entries: []EntryStatus{{Entry: entry}}, Repositories: []Repository{repository}}
|
||||
updates := AvailableUpdates(snapshot)
|
||||
if len(updates) != 1 || updates[0].ID != entry.ID || updates[0].AvailableVersion != "1.0" {
|
||||
t.Fatalf("missing release update: %+v", updates)
|
||||
}
|
||||
for _, changed := range []string{"url", "repository-id", "local-id", "version"} {
|
||||
candidate := snapshot
|
||||
candidate.Repositories = CloneRepositories(snapshot.Repositories)
|
||||
switch changed {
|
||||
case "url":
|
||||
candidate.Repositories[0].URL = "https://other.example.org/tentacles"
|
||||
case "repository-id":
|
||||
candidate.Repositories[0].ID = "example.replacement"
|
||||
case "local-id":
|
||||
candidate.Repositories[0].Tentacles[0].ID = "different"
|
||||
case "version":
|
||||
candidate.Repositories[0].Tentacles[0].Version = entry.Version
|
||||
}
|
||||
if updates := AvailableUpdates(candidate); len(updates) != 0 {
|
||||
t.Errorf("%s offered unrelated or equal release: %+v", changed, updates)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user