Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 47 additions & 4 deletions updater.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ type Config struct {
// repos do not have real attestations. Default (false) enables
// verification in production.
SkipAttestation bool

// StatePath, when non-empty, points to a JSON control file
// {"enabled": bool} that gates the AUTOMATIC update loop and is
// re-read on every tick. When the file is absent or {"enabled":
// false} the loop performs NO updates — so any deployment that sets
// StatePath is OFF BY DEFAULT until explicitly enabled (e.g. via
// `pilotctl update enable`). A manual one-shot RunOnce always runs,
// ignoring this gate. An empty StatePath preserves the legacy
// always-on loop behaviour for backward compatibility.
StatePath string
}

// Updater periodically checks GitHub Releases for new versions and optionally applies them.
Expand Down Expand Up @@ -106,21 +116,48 @@ func (u *Updater) Stop() {
// Start, it does not enter a periodic loop — it performs a single check
// (checking the pinned version or latest release), applies the update if
// available, and returns. Useful for one-shot invocations from
// `pilotctl update` and similar CLI commands.
// `pilotctl update` and similar CLI commands. It ALWAYS runs — the
// StatePath enabled-gate applies only to the automatic loop, so a manual
// `pilotctl update` works even when auto-update is disabled.
func (u *Updater) RunOnce() {
u.recoverPendingRestart()
u.checkOnce()
}

// enabled reports whether the automatic update loop may apply updates. With
// no StatePath configured it returns true (legacy always-on). Otherwise it
// reads the JSON control file and defaults to false (off) when the file is
// missing, unreadable, or malformed — auto-update is strictly opt-in.
func (u *Updater) enabled() bool {
if u.config.StatePath == "" {
return true
}
data, err := os.ReadFile(u.config.StatePath)
if err != nil {
return false
}
var s struct {
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(data, &s); err != nil {
return false
}
return s.Enabled
}

func (u *Updater) checkLoop() {
defer u.wg.Done()

// On startup, catch any missed daemon restart from a previous update cycle
// (e.g. old macOS updater replaced the binary but never called launchctl).
u.recoverPendingRestart()

// Run once immediately on start.
u.checkOnce()
// Run once immediately on start (only if auto-update is enabled).
if u.enabled() {
u.checkOnce()
} else {
slog.Info("auto-update disabled; loop idle until enabled", "state_path", u.config.StatePath)
}

ticker := time.NewTicker(u.config.CheckInterval)
defer ticker.Stop()
Expand All @@ -140,7 +177,13 @@ func (u *Updater) checkLoop() {
jitterTimer.Stop()
return
}
u.checkOnce()
// Re-read the gate each tick so `pilotctl update enable/disable`
// takes effect without restarting the updater.
if u.enabled() {
u.checkOnce()
} else {
slog.Debug("auto-update disabled; skipping tick")
}
case <-u.stopCh:
return
}
Expand Down
39 changes: 39 additions & 0 deletions zz_enabled_gate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package updater

import (
"os"
"path/filepath"
"testing"
)

// TestEnabledGate pins the auto-update opt-in semantics: empty StatePath is
// legacy always-on; a configured StatePath is OFF by default (missing/false/
// malformed) and only on when the file says {"enabled": true}.
func TestEnabledGate(t *testing.T) {
if !New(Config{}).enabled() {
t.Error("empty StatePath must be enabled (legacy always-on)")
}
sp := filepath.Join(t.TempDir(), "auto-update.json")
u := New(Config{StatePath: sp})
if u.enabled() {
t.Error("missing state file must be DISABLED (off by default)")
}
for _, tc := range []struct {
body string
want bool
}{
{`{"enabled": false}`, false},
{`{"enabled": true}`, true},
{`not json`, false},
{`{}`, false},
} {
if err := os.WriteFile(sp, []byte(tc.body), 0o644); err != nil {
t.Fatal(err)
}
if got := u.enabled(); got != tc.want {
t.Errorf("state %q: enabled()=%v want %v", tc.body, got, tc.want)
}
}
}