mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-08 22:49:10 +02:00
* [client] Keep the updater silent until management decides the mode The update manager started in download-only mode and every engine stop reset it there again, kicking the update loop with the cached latest version. On a managed peer this published a "New version available" notification on each disconnect, session expiry or logout, and once more in the window between daemon start and the first network map. Users under enforced updates then installed the release by hand. Replace the boolean with a three-state mode. The manager starts undecided and publishes nothing, from the fetcher or from NotifyUI, until the network map picks download-only or managed. A new connection lifecycle resets to undecided; disconnects leave the last decision untouched. A missing AutoUpdateSettings now means download-only instead of a no-op, and on platforms without an installer SetVersion falls back to download-only so they keep notifying. * [client] Address review findings on the updater mode split Route every mode transition through one locked helper so the staged managed version cannot outlive the decision that created it. Both download-only transitions kept pendingVersion, letting Install put on the old enforced release after management disabled updates or the platform fell back to download-only. The helper also bumps a generation counter. handleUpdate and NotifyUI copy the mode under the mutex but publish and install after releasing it, so a reset landing in that window could still emit an enforced notification. Both now recheck the generation right before the side effect and drop the stale work. Reset on every connection attempt instead of once per run: the backoff loop reconnects without re-entering run, and since disconnects no longer force download-only, a fetch in the disconnected gap republished the enforced notification before the new network map arrived. A missing AutoUpdateSettings now logs that it defaults to download-only rather than claiming auto-update is disabled, which contradicted the notifications that mode still emits. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [client] Reset the updater mode on engine stop Removing SetDownloadOnly from the engine teardown also dropped the reset of the forced-update flag. After netbird down the manager kept the last management directive, so a release published while the client was stopped could still trigger a forced install that management no longer asked for. The same window existed between an engine teardown and the next reconnect attempt, where ResetMode only ran at the start of the attempt. Reset the mode in stopLocked instead, so the updater stays silent from the moment the engine goes down until the first network map of the next connection decides the mode again. * [client] Name the force-reset test for the behavior it proves The silence after ResetMode follows from the undecided mode alone, and the closing SetVersion overwrites forceUpdate from its argument, so the test cannot observe whether the flag itself was cleared. Name it for the stale directive staying silent, which is what it asserts. * [client] Fall back to download-only on a malformed expected version * [client] Compare the full auto-update directive before skipping it * [client] Set the mock latest version under its lock in the updater tests --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
228 lines
7.2 KiB
Go
228 lines
7.2 KiB
Go
//go:build windows || darwin
|
|
|
|
package updater
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path"
|
|
"testing"
|
|
"time"
|
|
|
|
v "github.com/hashicorp/go-version"
|
|
|
|
"github.com/netbirdio/netbird/client/internal/peer"
|
|
"github.com/netbirdio/netbird/client/internal/statemanager"
|
|
cProto "github.com/netbirdio/netbird/client/proto"
|
|
)
|
|
|
|
func Test_LatestVersion(t *testing.T) {
|
|
testMatrix := []struct {
|
|
name string
|
|
daemonVersion string
|
|
initialLatestVersion *v.Version
|
|
latestVersion *v.Version
|
|
shouldUpdateInit bool
|
|
shouldUpdateLater bool
|
|
}{
|
|
{
|
|
name: "Should notify again when a newer version arrives even within 5 minutes",
|
|
daemonVersion: "1.0.0",
|
|
initialLatestVersion: v.Must(v.NewSemver("1.0.1")),
|
|
latestVersion: v.Must(v.NewSemver("1.0.2")),
|
|
shouldUpdateInit: true,
|
|
shouldUpdateLater: true,
|
|
},
|
|
{
|
|
name: "Shouldn't update initially, but should update as soon as latest version is fetched",
|
|
daemonVersion: "1.0.0",
|
|
initialLatestVersion: nil,
|
|
latestVersion: v.Must(v.NewSemver("1.0.1")),
|
|
shouldUpdateInit: false,
|
|
shouldUpdateLater: true,
|
|
},
|
|
}
|
|
|
|
for idx, c := range testMatrix {
|
|
mockUpdate := &versionUpdateMock{latestVersion: c.initialLatestVersion}
|
|
tmpFile := path.Join(t.TempDir(), fmt.Sprintf("update-test-%d.json", idx))
|
|
recorder := peer.NewRecorder("")
|
|
sub := recorder.SubscribeToEvents()
|
|
defer recorder.UnsubscribeFromEvents(sub)
|
|
|
|
m := NewManager(recorder, statemanager.New(tmpFile))
|
|
m.update = mockUpdate
|
|
m.currentVersion = c.daemonVersion
|
|
m.autoUpdateSupported = func() bool { return true }
|
|
m.Start(context.Background())
|
|
m.SetVersion("latest", false)
|
|
|
|
ver, _ := waitForUpdateEvent(sub, 500*time.Millisecond)
|
|
triggeredInit := ver != ""
|
|
if triggeredInit != c.shouldUpdateInit {
|
|
t.Errorf("%s: Initial update trigger mismatch, expected %v, got %v", c.name, c.shouldUpdateInit, triggeredInit)
|
|
}
|
|
if triggeredInit && c.initialLatestVersion != nil && ver != c.initialLatestVersion.String() {
|
|
t.Errorf("%s: Initial update version mismatch, expected %v, got %v", c.name, c.initialLatestVersion.String(), ver)
|
|
}
|
|
|
|
mockUpdate.setLatestVersion(c.latestVersion)
|
|
mockUpdate.onUpdate()
|
|
|
|
ver, _ = waitForUpdateEvent(sub, 500*time.Millisecond)
|
|
triggeredLater := ver != ""
|
|
if triggeredLater != c.shouldUpdateLater {
|
|
t.Errorf("%s: Later update trigger mismatch, expected %v, got %v", c.name, c.shouldUpdateLater, triggeredLater)
|
|
}
|
|
if triggeredLater && c.latestVersion != nil && ver != c.latestVersion.String() {
|
|
t.Errorf("%s: Later update version mismatch, expected %v, got %v", c.name, c.latestVersion.String(), ver)
|
|
}
|
|
|
|
m.Stop()
|
|
}
|
|
}
|
|
|
|
func Test_HandleUpdate(t *testing.T) {
|
|
testMatrix := []struct {
|
|
name string
|
|
daemonVersion string
|
|
latestVersion *v.Version
|
|
expectedVersion string
|
|
shouldUpdate bool
|
|
}{
|
|
{
|
|
name: "Install to a specific version should update regardless of if latestVersion is available yet",
|
|
daemonVersion: "0.55.0",
|
|
latestVersion: nil,
|
|
expectedVersion: "0.56.0",
|
|
shouldUpdate: true,
|
|
},
|
|
{
|
|
name: "Install to specific version should not update if version matches",
|
|
daemonVersion: "0.55.0",
|
|
latestVersion: nil,
|
|
expectedVersion: "0.55.0",
|
|
shouldUpdate: false,
|
|
},
|
|
{
|
|
name: "Install to specific version should not update if current version is newer",
|
|
daemonVersion: "0.55.0",
|
|
latestVersion: nil,
|
|
expectedVersion: "0.54.0",
|
|
shouldUpdate: false,
|
|
},
|
|
{
|
|
name: "Install to latest version should update if latest is newer",
|
|
daemonVersion: "0.55.0",
|
|
latestVersion: v.Must(v.NewSemver("0.56.0")),
|
|
expectedVersion: "latest",
|
|
shouldUpdate: true,
|
|
},
|
|
{
|
|
name: "Install to latest version should not update if latest == current",
|
|
daemonVersion: "0.56.0",
|
|
latestVersion: v.Must(v.NewSemver("0.56.0")),
|
|
expectedVersion: "latest",
|
|
shouldUpdate: false,
|
|
},
|
|
{
|
|
name: "Should not update if daemon version is invalid",
|
|
daemonVersion: "development",
|
|
latestVersion: v.Must(v.NewSemver("1.0.0")),
|
|
expectedVersion: "latest",
|
|
shouldUpdate: false,
|
|
},
|
|
{
|
|
name: "Should not update if expecting latest and latest version is unavailable",
|
|
daemonVersion: "0.55.0",
|
|
latestVersion: nil,
|
|
expectedVersion: "latest",
|
|
shouldUpdate: false,
|
|
},
|
|
{
|
|
name: "Should not update if expected version is invalid",
|
|
daemonVersion: "0.55.0",
|
|
latestVersion: nil,
|
|
expectedVersion: "development",
|
|
shouldUpdate: false,
|
|
},
|
|
}
|
|
for idx, c := range testMatrix {
|
|
tmpFile := path.Join(t.TempDir(), fmt.Sprintf("update-test-%d.json", idx))
|
|
recorder := peer.NewRecorder("")
|
|
sub := recorder.SubscribeToEvents()
|
|
defer recorder.UnsubscribeFromEvents(sub)
|
|
|
|
m := NewManager(recorder, statemanager.New(tmpFile))
|
|
m.update = &versionUpdateMock{latestVersion: c.latestVersion}
|
|
m.currentVersion = c.daemonVersion
|
|
m.autoUpdateSupported = func() bool { return true }
|
|
m.Start(context.Background())
|
|
m.SetVersion(c.expectedVersion, false)
|
|
|
|
ver, _ := waitForUpdateEvent(sub, 500*time.Millisecond)
|
|
updateTriggered := ver != ""
|
|
|
|
if updateTriggered {
|
|
if c.expectedVersion == "latest" && c.latestVersion != nil && ver != c.latestVersion.String() {
|
|
t.Errorf("%s: Version mismatch, expected %v, got %v", c.name, c.latestVersion.String(), ver)
|
|
} else if c.expectedVersion != "latest" && c.expectedVersion != "development" && ver != c.expectedVersion {
|
|
t.Errorf("%s: Version mismatch, expected %v, got %v", c.name, c.expectedVersion, ver)
|
|
}
|
|
}
|
|
|
|
if updateTriggered != c.shouldUpdate {
|
|
t.Errorf("%s: Update trigger mismatch, expected %v, got %v", c.name, c.shouldUpdate, updateTriggered)
|
|
}
|
|
m.Stop()
|
|
}
|
|
}
|
|
|
|
func Test_EnforcedMetadata(t *testing.T) {
|
|
// Mode 1 (downloadOnly): no enforced metadata
|
|
tmpFile := path.Join(t.TempDir(), "update-test-mode1.json")
|
|
recorder := peer.NewRecorder("")
|
|
sub := recorder.SubscribeToEvents()
|
|
defer recorder.UnsubscribeFromEvents(sub)
|
|
|
|
m := NewManager(recorder, statemanager.New(tmpFile))
|
|
m.update = &versionUpdateMock{latestVersion: v.Must(v.NewSemver("1.0.1"))}
|
|
m.currentVersion = "1.0.0"
|
|
m.Start(context.Background())
|
|
m.SetDownloadOnly()
|
|
|
|
ver, enforced := waitForUpdateEvent(sub, 500*time.Millisecond)
|
|
if ver == "" {
|
|
t.Fatal("Mode 1: expected new_version_available event")
|
|
}
|
|
if enforced {
|
|
t.Error("Mode 1: expected no enforced metadata")
|
|
}
|
|
m.Stop()
|
|
|
|
// Mode 2 (enforced, forceUpdate=false): enforced metadata present, no auto-install
|
|
tmpFile2 := path.Join(t.TempDir(), "update-test-mode2.json")
|
|
recorder2 := peer.NewRecorder("")
|
|
sub2 := recorder2.SubscribeToEvents()
|
|
defer recorder2.UnsubscribeFromEvents(sub2)
|
|
|
|
m2 := NewManager(recorder2, statemanager.New(tmpFile2))
|
|
m2.update = &versionUpdateMock{latestVersion: nil}
|
|
m2.currentVersion = "1.0.0"
|
|
m2.autoUpdateSupported = func() bool { return true }
|
|
m2.Start(context.Background())
|
|
m2.SetVersion("1.0.1", false)
|
|
|
|
ver, enforced2 := waitForUpdateEvent(sub2, 500*time.Millisecond)
|
|
if ver == "" {
|
|
t.Fatal("Mode 2: expected new_version_available event")
|
|
}
|
|
if !enforced2 {
|
|
t.Error("Mode 2: expected enforced metadata")
|
|
}
|
|
m2.Stop()
|
|
}
|
|
|
|
// ensure the proto import is used
|
|
var _ = cProto.SystemEvent_INFO
|