Files
kandev/apps/backend/internal/system/updates/service.go
T
José Almeida 041a19f060 feat: service-managed UI self-update (#1210)
* feat: add service-managed self-update

* chore: add temporary self-update test scripts

* chore: simplify temporary script markdown fences

* chore: run frontend in temporary self-update scripts

* fix: show self-update job progress after apply

* chore: add real npm self-update test script

* fix: isolate npm self-update prefix

* fix: detect npm service installs from bin shim

* chore: reset service before real npm update test

* chore: expand real npm status diagnostics

* fix: preserve service home during self-update

* fix(cli): tee self-update helper output to a log file

* fix(cli): avoid launchd bootstrap race when reloading the macOS service

* fix(backend): run macOS self-update helper once via transient launchd plist

* fix(web): show self-update progress and lock the apply button until the update lands

* fix(cli): reinstall via the kandev wrapper on Homebrew self-update

* chore: remove temp self-update test scripts

* fix(web): drop unused apply path from useUpdates hook

* fix(backend): snapshot install state once in apply preflight

* docs: align self-update docs with transient LaunchAgent flow

* chore(release): run cli bundle from apps workspace root

* fix(backend): guard against concurrent self-update applies

* fix(backend): check scheme and helper paths in self-update apply

* fix(backend): drop duplicate binary name from npx manual command

* fix(cli): allow --help for service self-update without --intent

* fix(cli): harden self-update dir perms and darwin uid resolution

* fix(web): set required POST fields last in applyUpdate

* docs: use JSON body notation for the apply endpoint

* fix(backend): expire self-update guard so a stuck helper cannot wedge apply

* refactor(cli): collapse redundant writeSync branch in self-update log
2026-06-01 17:49:15 +01:00

402 lines
14 KiB
Go

// Package updates implements the kandev background updates poller and the
// HTTP surface for the System -> Updates page. It polls GitHub Releases every
// 6 hours, persists the latest tag in the kandev_meta key/value table, and
// exposes a 30s rate-limited "check now" handler.
package updates
import (
"context"
"errors"
"net/http"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/persistence"
"github.com/kandev/kandev/internal/system/jobs"
)
// PollInterval is the cadence at which the background goroutine polls GitHub.
const PollInterval = 6 * time.Hour
// ManualCheckWindow is the minimum gap between two manual /check calls.
const ManualCheckWindow = 30 * time.Second
// applyGuardTTL bounds how long a single self-update launch blocks further
// applies. It matches the frontend progress timeout: past this window we assume
// the helper failed without restarting the backend and allow a retry, instead
// of wedging apply behind a permanent 409.
const applyGuardTTL = 5 * time.Minute
// ErrRateLimited indicates that a manual check was denied because the
// per-process rate limiter window has not yet elapsed.
var ErrRateLimited = errors.New("updates check rate limited")
// ErrApplyConfirm indicates that POST /updates/apply did not include the
// explicit confirmation token.
var ErrApplyConfirm = errors.New("update apply requires confirm=UPDATE")
// ErrApplyUnsupported indicates that the current installation cannot be
// updated safely from the UI.
var ErrApplyUnsupported = errors.New("update apply unsupported")
// ErrNoUpdateAvailable indicates that the cached release state does not show
// a newer semver than the running binary.
var ErrNoUpdateAvailable = errors.New("no update available")
// ErrApplyInProgress indicates that a self-update helper has already been
// launched and not yet completed, so a second apply is refused.
var ErrApplyInProgress = errors.New("a self-update is already in progress")
// devVersion is the sentinel used by the ldflags-injected current version
// when no release tag was baked in (i.e. local dev builds).
const devVersion = "dev"
// UpdatesResponse is the JSON shape returned by both Get() and Check().
type UpdatesResponse struct {
Current string `json:"current"`
Latest string `json:"latest"`
LatestURL string `json:"latest_url"`
LatestCheckedAt time.Time `json:"latest_checked_at"`
UpdateAvailable bool `json:"update_available"`
Install InstallStateResponse `json:"install"`
ApplySupported bool `json:"apply_supported"`
ApplyUnsupportedReason string `json:"apply_unsupported_reason,omitempty"`
ManualCommands []string `json:"manual_commands,omitempty"`
}
// ApplyResponse is the 202 payload returned when a self-update helper has
// been queued.
type ApplyResponse struct {
JobID string `json:"job_id"`
}
// InstallStateResponse describes whether this backend is currently running
// under a kandev-managed service unit/plist. The UI uses this to hard-gate
// one-click updates.
type InstallStateResponse struct {
RunningAsService bool `json:"running_as_service"`
ManagedService bool `json:"managed_service"`
Mode string `json:"mode,omitempty"`
Manager string `json:"manager,omitempty"`
Kind string `json:"kind,omitempty"`
MetadataPath string `json:"metadata_path,omitempty"`
}
// releaseURL is the GitHub endpoint the service polls. Tests override it via
// SetReleaseURL.
//
// Service holds the wiring needed to drive the poller and serve the two
// HTTP endpoints.
type Service struct {
pool *db.Pool
current string
httpClient *http.Client
log *logger.Logger
limiter *Limiter
jobs *jobs.Tracker
homeDir string
getenv func(string) string
now func() time.Time
applyRun applyRunner
// applyStartedAt holds the unix-nano timestamp of the last self-update launch
// (0 = none in flight). It guards against two concurrent /updates/apply calls
// each launching a helper that would race the reinstall/restart, and
// self-expires after applyGuardTTL so a helper that exits 0 but never restarts
// the backend cannot wedge apply behind a permanent 409.
applyStartedAt atomic.Int64
// releaseURL is the GitHub endpoint hit by Check + the poller; defaults
// to DefaultReleaseURL and can be overridden by SetReleaseURL for tests.
releaseURL string
// fetcher is the function used to retrieve the latest release. Defaults
// to FetchLatestReleaseFrom(httpClient). Tests inject a deterministic
// stub via SetFetcher so the synctest poller test does not block on
// real network I/O (which sits outside the fake-time bubble and prevents
// synctest.Wait from settling).
fetcher Fetcher
// mu protects pollerStarted/cancel/wg under concurrent Start calls.
mu sync.Mutex
pollerStarted bool
pollerCancel context.CancelFunc
pollerWg sync.WaitGroup
pollerInterval time.Duration
}
// Fetcher abstracts the GitHub release call so tests can drive
// the poller and Check() without spinning up an httptest server.
type Fetcher func(ctx context.Context) (tag, url string, err error)
// Option customises Service construction without growing NewService's public
// parameter list.
type Option func(*Service)
// WithJobs wires the system job tracker used by Apply.
func WithJobs(tracker *jobs.Tracker) Option {
return func(s *Service) {
s.jobs = tracker
}
}
// WithHomeDir sets the resolved Kandev home directory. It is used as a
// fallback for service metadata discovery.
func WithHomeDir(homeDir string) Option {
return func(s *Service) {
s.homeDir = homeDir
}
}
// WithEnvReader overrides environment reads. Intended for tests.
func WithEnvReader(getenv func(string) string) Option {
return func(s *Service) {
if getenv != nil {
s.getenv = getenv
}
}
}
// WithApplyRunner overrides helper launch. Intended for tests and e2e mocks.
func WithApplyRunner(r applyRunner) Option {
return func(s *Service) {
if r != nil {
s.applyRun = r
}
}
}
// NewService constructs the updates service. When httpClient is nil, a fresh
// client carrying defaultClientTimeout is allocated — http.DefaultClient has
// no timeout, so handing it the poller would let a stalled socket hang a
// goroutine for hours. current is the running binary version (typically
// injected via ldflags); the sentinel "dev" disables UpdateAvailable.
func NewService(pool *db.Pool, current string, httpClient *http.Client, log *logger.Logger, opts ...Option) *Service {
if httpClient == nil {
httpClient = &http.Client{Timeout: defaultClientTimeout}
}
if log == nil {
log = logger.Default()
}
homeDir := ""
if dir, err := os.UserHomeDir(); err == nil {
homeDir = filepath.Join(dir, ".kandev")
}
s := &Service{
pool: pool,
current: current,
httpClient: httpClient,
log: log,
limiter: NewLimiter(ManualCheckWindow),
releaseURL: DefaultReleaseURL,
pollerInterval: PollInterval,
homeDir: homeDir,
getenv: os.Getenv,
now: time.Now,
}
s.fetcher = s.defaultFetcher
s.applyRun = s.defaultApplyRunner
for _, opt := range opts {
opt(s)
}
return s
}
// defaultFetcher delegates to FetchLatestReleaseFrom using the current
// releaseURL + httpClient. Re-evaluating both on each call honours
// SetReleaseURL after construction.
func (s *Service) defaultFetcher(ctx context.Context) (string, string, error) {
s.mu.Lock()
url := s.releaseURL
s.mu.Unlock()
return FetchLatestReleaseFrom(ctx, s.httpClient, url)
}
// SetFetcher overrides the GitHub fetch implementation. Intended for tests
// that need deterministic behaviour inside testing/synctest.
func (s *Service) SetFetcher(f Fetcher) {
s.mu.Lock()
if f == nil {
s.fetcher = s.defaultFetcher
} else {
s.fetcher = f
}
s.mu.Unlock()
}
// SetReleaseURL overrides the GitHub endpoint used by the poller and Check().
// Intended for tests that point at a httptest stub server.
func (s *Service) SetReleaseURL(url string) {
s.mu.Lock()
s.releaseURL = url
s.mu.Unlock()
}
// Get returns the last-known state from kandev_meta without contacting
// GitHub. Safe to call on every page load.
func (s *Service) Get() (UpdatesResponse, error) {
version, url, checkedAt, err := persistence.ReadLatestVersion(s.pool.Reader())
if err != nil {
return UpdatesResponse{}, err
}
return s.buildResponse(version, url, checkedAt), nil
}
// Check forces a synchronous poll against GitHub. Rate-limited per process by
// the 30s Limiter. On success the result is persisted and returned. On
// failure the previously persisted values are returned unchanged.
func (s *Service) Check(ctx context.Context) (UpdatesResponse, error) {
if ok, _ := s.limiter.Allow(); !ok {
return UpdatesResponse{}, ErrRateLimited
}
return s.fetchAndPersist(ctx)
}
// Apply validates the cached update and current service install, writes an
// intent file, and queues the OS-manager helper that performs the actual
// package upgrade + service reinstall.
func (s *Service) Apply(ctx context.Context, confirm string) (string, error) {
if confirm != "UPDATE" {
return "", ErrApplyConfirm
}
if s.jobs == nil {
return "", ErrApplyUnsupported
}
resp, metadata, err := s.applyPreflight()
if err != nil {
return "", err
}
// Claim the in-flight guard across the launch. A successful launch keeps it
// held because the helper restarts the backend shortly (which clears it by
// replacing the process); a launch error releases it for an immediate retry.
// The guard self-expires after applyGuardTTL (see claimApplyGuard) so a
// helper that exits 0 but never restarts cannot block apply forever.
if !s.claimApplyGuard() {
return "", ErrApplyInProgress
}
intentPath, intent, err := s.writeApplyIntent(resp, metadata)
if err != nil {
s.applyStartedAt.Store(0)
return "", err
}
req := applyRequest{
IntentPath: intentPath,
Intent: intent,
}
jobID := s.jobs.Start(ctx, "self-update", func(jobCtx context.Context) (map[string]interface{}, error) {
result, runErr := s.applyRun(jobCtx, req)
if runErr != nil {
s.applyStartedAt.Store(0)
}
return result, runErr
})
return jobID, nil
}
// claimApplyGuard atomically reserves the self-update guard. It succeeds when no
// apply is in flight or the previous one started more than applyGuardTTL ago
// (assumed dead); it returns false while a recent apply still holds it.
func (s *Service) claimApplyGuard() bool {
now := s.now().UnixNano()
for {
prev := s.applyStartedAt.Load()
if prev != 0 && now-prev < applyGuardTTL.Nanoseconds() {
return false
}
if s.applyStartedAt.CompareAndSwap(prev, now) {
return true
}
}
}
// RetryAfter exposes the limiter's remaining window so the HTTP handler can
// surface a Retry-After value to clients.
func (s *Service) RetryAfter() time.Duration {
_, retry := s.peekLimiter()
return retry
}
func (s *Service) peekLimiter() (bool, time.Duration) {
// peek without consuming: use a separate limiter with the same window/clock
// is overkill; instead we synthesise a dry-run by inspecting state.
s.limiter.mu.Lock()
defer s.limiter.mu.Unlock()
now := s.limiter.now()
if s.limiter.last.IsZero() || now.Sub(s.limiter.last) >= s.limiter.window {
return true, 0
}
retry := s.limiter.window - now.Sub(s.limiter.last)
if retry <= 0 {
retry = time.Nanosecond
}
return false, retry
}
// fetchAndPersist hits GitHub, persists the result on success, and always
// returns the now-current view of kandev_meta. A fetch failure preserves the
// previously persisted state and returns the underlying error.
func (s *Service) fetchAndPersist(ctx context.Context) (UpdatesResponse, error) {
s.mu.Lock()
fetch := s.fetcher
s.mu.Unlock()
tag, releaseURL, err := fetch(ctx)
if err != nil {
// Preserve persisted state; surface the error to caller.
current, _ := s.Get()
return current, err
}
now := time.Now().UTC()
if werr := persistence.WriteLatestVersion(s.pool.Writer(), tag, releaseURL, now); werr != nil {
s.log.Warn("updates: persist latest version failed", zap.Error(werr))
return UpdatesResponse{}, werr
}
return s.buildResponse(tag, releaseURL, now), nil
}
func (s *Service) buildResponse(latest, url string, checkedAt time.Time) UpdatesResponse {
install, _ := s.detectInstallState()
return s.buildResponseFrom(install, latest, url, checkedAt)
}
// buildResponseFrom assembles the response from an already-detected install
// state. Apply reuses this so the ApplySupported gate and the intent file are
// derived from a single install-state snapshot rather than two independent
// reads (closing a narrow TOCTOU window).
func (s *Service) buildResponseFrom(install InstallStateResponse, latest, url string, checkedAt time.Time) UpdatesResponse {
applySupported, reason := install.applySupport()
return UpdatesResponse{
Current: s.current,
Latest: latest,
LatestURL: url,
LatestCheckedAt: checkedAt,
UpdateAvailable: s.updateAvailable(latest),
Install: install,
ApplySupported: applySupported,
ApplyUnsupportedReason: reason,
ManualCommands: manualCommands(install, latest),
}
}
// updateAvailable returns true iff latest is a valid semver strictly greater
// than current. Current = "dev" or empty disables the flag.
func (s *Service) updateAvailable(latest string) bool {
if latest == "" || !isValidSemver(latest) {
return false
}
if s.current == "" || s.current == devVersion {
return false
}
if !isValidSemver(s.current) {
return false
}
return compareSemver(latest, s.current) > 0
}