From a61c79fa6952b7361480eae696fab409e6729c15 Mon Sep 17 00:00:00 2001 From: Veillax <126523099+Veillax@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:56:55 -0600 Subject: [PATCH] selfupdate: report progress events; reports carry update stage, percent and log Co-Authored-By: Claude Sonnet 5.5 --- model/model.go | 8 ++++ selfupdate/selfupdate.go | 80 ++++++++++++++++++++++++++++++----- selfupdate/selfupdate_test.go | 43 +++++++++++++++++++ 3 files changed, 121 insertions(+), 10 deletions(-) diff --git a/model/model.go b/model/model.go index af61672..ffd72ff 100644 --- a/model/model.go +++ b/model/model.go @@ -308,6 +308,10 @@ type EdgeReport struct { AutoUpdate bool `json:"autoUpdate,omitempty"` UpdateMsg string `json:"updateMsg,omitempty"` // outcome of the update asked for by UpdateID UpdateID string `json:"updateId,omitempty"` + // progress of that update, for the dashboard's progress bar and log + UpdateStage string `json:"updateStage,omitempty"` + UpdatePct int `json:"updatePct,omitempty"` + UpdateLog []string `json:"updateLog,omitempty"` } // EdgeRef tells a site where to dial. @@ -340,6 +344,10 @@ type SiteReport struct { AutoUpdate bool `json:"autoUpdate,omitempty"` UpdateMsg string `json:"updateMsg,omitempty"` UpdateID string `json:"updateId,omitempty"` + // progress of that update, for the dashboard's progress bar and log + UpdateStage string `json:"updateStage,omitempty"` + UpdatePct int `json:"updatePct,omitempty"` + UpdateLog []string `json:"updateLog,omitempty"` } // HostMatches reports whether host matches an exact name or a "*.example.com" pattern. diff --git a/selfupdate/selfupdate.go b/selfupdate/selfupdate.go index 96ee6b3..d51cac0 100644 --- a/selfupdate/selfupdate.go +++ b/selfupdate/selfupdate.go @@ -29,6 +29,15 @@ import ( const maxArchive = 300 << 20 +// Event is one step of an update, for showing progress: the stage ("checking", "downloading", +// "verifying", "installing", "restarting", "done" or "failed"), how far along it is (0-100, or -1 to +// leave the bar where it was) and, when there is something worth showing in a log, a line of text. +type Event struct { + Stage string + Pct int + Line string +} + // Config says what to update and where from. type Config struct { Server string // e.g. https://git.veillax.com @@ -38,6 +47,8 @@ type Config struct { // Interval between checks (default 6 hours). Interval time.Duration Logf func(format string, args ...any) + // Progress, if set, is told about every step of an update (it is called from the updating goroutine). + Progress func(Event) // The fields below exist for tests and unusual setups. HTTP *http.Client @@ -103,23 +114,40 @@ func Run(ctx context.Context, cfg Config) { } } +func (c Config) say(stage string, pct int, line string) { + if c.Progress != nil { + c.Progress(Event{Stage: stage, Pct: pct, Line: line}) + } +} + // CheckOnce looks for a newer release and installs it. It returns the new tag when it // installed one (and Restart returned, which only happens in tests or on failure). func CheckOnce(ctx context.Context, cfg Config) (string, error) { cfg.defaults() + tag, err := checkOnce(ctx, cfg) + if err != nil { + cfg.say("failed", -1, "Failed: "+err.Error()) + } + return tag, err +} + +func checkOnce(ctx context.Context, cfg Config) (string, error) { cur, ok := parseVersion(cfg.Current) if !ok { return "", fmt.Errorf("%q is not a release version", cfg.Current) } + cfg.say("checking", 2, "Checking "+cfg.Server+" for the newest release of "+cfg.App) rel, err := latestRelease(ctx, cfg) if err != nil { return "", err } if rel.Prerelease || rel.Draft { + cfg.say("done", 100, "Already on the newest release ("+cfg.Current+")") return "", nil } latest, ok := parseVersion(rel.Tag) if !ok || !latest.newerThan(cur) { + cfg.say("done", 100, "Already on the newest release ("+cfg.Current+")") return "", nil // up to date, or something we don't understand: never downgrade } @@ -140,8 +168,9 @@ func CheckOnce(ctx context.Context, cfg Config) (string, error) { return "", fmt.Errorf("release %s has no %s with SHA256SUMS yet (it may still be building); will retry", rel.Tag, archive) } cfg.Logf("auto-update: %s is available (running %s); downloading %s", rel.Tag, cfg.Current, archive) + cfg.say("checking", 5, rel.Tag+" is available (running "+cfg.Current+")") - sums, err := get(ctx, cfg.HTTP, sumsURL, 1<<20) + sums, err := get(ctx, cfg.HTTP, sumsURL, 1<<20, nil) if err != nil { return "", err } @@ -155,14 +184,31 @@ func CheckOnce(ctx context.Context, cfg Config) (string, error) { if want == "" { return "", fmt.Errorf("SHA256SUMS does not list %s", archive) } - data, err := get(ctx, cfg.HTTP, archiveURL, maxArchive) + cfg.say("downloading", 6, "Downloading "+archive) + lastQuarter := 0 + data, err := get(ctx, cfg.HTTP, archiveURL, maxArchive, func(done, total int64) { + if total <= 0 { + cfg.say("downloading", -1, "") + return + } + pct := 6 + int(80*done/total) + line := "" + if q := int(100 * done / total / 25); q > lastQuarter { + lastQuarter = q + line = fmt.Sprintf("Downloaded %.1f of %.1f MB (%d%%)", float64(done)/1e6, float64(total)/1e6, 100*done/total) + } + cfg.say("downloading", pct, line) + }) if err != nil { return "", err } + cfg.say("verifying", 88, "Verifying the checksum") got := sha256.Sum256(data) if hex.EncodeToString(got[:]) != want { return "", fmt.Errorf("checksum mismatch for %s: not installing", archive) } + cfg.say("verifying", 90, "The checksum matches") + cfg.say("installing", 92, "Unpacking the new binary") bin, err := extract(data, cfg.App) if err != nil { return "", err @@ -181,15 +227,18 @@ func CheckOnce(ctx context.Context, cfg Config) (string, error) { if err := os.WriteFile(tmp, bin, 0o755); err != nil { return "", fmt.Errorf("can't write next to %s (is its folder writable by this user?): %w", exe, err) } + cfg.say("installing", 94, "Checking that the new binary starts") if err := cfg.Verify(tmp, rel.Tag); err != nil { os.Remove(tmp) return "", fmt.Errorf("the new binary failed its check, keeping the old one: %w", err) } + cfg.say("installing", 97, "Replacing "+exe) if err := os.Rename(tmp, exe); err != nil { // atomic; the running process keeps its old file os.Remove(tmp) return "", fmt.Errorf("replacing %s: %w", exe, err) } cfg.Logf("auto-update: installed %s, restarting", rel.Tag) + cfg.say("restarting", 100, "Installed "+rel.Tag+"; restarting into it") if err := cfg.Restart(exe); err != nil { return rel.Tag, fmt.Errorf("installed %s but could not restart into it (it will start with the next restart): %w", rel.Tag, err) } @@ -208,7 +257,7 @@ type release struct { func latestRelease(ctx context.Context, cfg Config) (release, error) { var rel release - body, err := get(ctx, cfg.HTTP, cfg.Server+"/api/v1/repos/"+cfg.Repo+"/releases/latest", 1<<20) + body, err := get(ctx, cfg.HTTP, cfg.Server+"/api/v1/repos/"+cfg.Repo+"/releases/latest", 1<<20, nil) if err != nil { return rel, fmt.Errorf("checking for a new release: %w", err) } @@ -237,7 +286,7 @@ func Check(ctx context.Context, cfg Config) (latest string, newer bool, err erro return rel.Tag, ok && l.newerThan(cur), nil } -func get(ctx context.Context, c *http.Client, url string, max int64) ([]byte, error) { +func get(ctx context.Context, c *http.Client, url string, max int64, onProgress func(done, total int64)) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err @@ -251,12 +300,23 @@ func get(ctx context.Context, c *http.Client, url string, max int64) ([]byte, er if resp.StatusCode != 200 { return nil, fmt.Errorf("%s: %s", url, resp.Status) } - b, err := io.ReadAll(io.LimitReader(resp.Body, max+1)) - if err != nil { - return nil, err - } - if int64(len(b)) > max { - return nil, fmt.Errorf("%s is larger than %d bytes", url, max) + var b []byte + buf := make([]byte, 64<<10) + for { + n, err := resp.Body.Read(buf) + b = append(b, buf[:n]...) + if int64(len(b)) > max { + return nil, fmt.Errorf("%s is larger than %d bytes", url, max) + } + if onProgress != nil && n > 0 { + onProgress(int64(len(b)), resp.ContentLength) + } + if err == io.EOF { + break + } + if err != nil { + return nil, err + } } return b, nil } diff --git a/selfupdate/selfupdate_test.go b/selfupdate/selfupdate_test.go index 5733480..8dab6c5 100644 --- a/selfupdate/selfupdate_test.go +++ b/selfupdate/selfupdate_test.go @@ -152,3 +152,46 @@ func TestCheckReportsWithoutInstalling(t *testing.T) { } } } + +func TestReportsProgressThroughTheWholeUpdate(t *testing.T) { + cfg, _, _ := setup(t, fakeGitea(t, "v0.2.0", false, false), "v0.1.0") + var events []Event + cfg.Progress = func(e Event) { events = append(events, e) } + if _, err := CheckOnce(context.Background(), cfg); err != nil { + t.Fatal(err) + } + var stages []string + last, lines := 0, "" + for _, e := range events { + if len(stages) == 0 || stages[len(stages)-1] != e.Stage { + stages = append(stages, e.Stage) + } + if e.Pct >= 0 { + if e.Pct < last { + t.Errorf("progress went backwards: %d after %d (%+v)", e.Pct, last, e) + } + last = e.Pct + } + lines += e.Line + "\n" + } + if got := strings.Join(stages, ","); got != "checking,downloading,verifying,installing,restarting" { + t.Errorf("stages: %s", got) + } + if last != 100 || !strings.Contains(lines, "v0.2.0 is available") || !strings.Contains(lines, "checksum matches") { + t.Errorf("progress ended at %d with log:\n%s", last, lines) + } +} + +func TestProgressReportsFailureAndNothingToDo(t *testing.T) { + cfg, _, _ := setup(t, fakeGitea(t, "v0.2.0", false, true), "v0.1.0") + var last Event + cfg.Progress = func(e Event) { last = e } + if _, err := CheckOnce(context.Background(), cfg); err == nil || last.Stage != "failed" || !strings.Contains(last.Line, "checksum") { + t.Errorf("err=%v last=%+v", err, last) + } + cfg, _, _ = setup(t, fakeGitea(t, "v0.2.0", false, false), "v0.2.0") + cfg.Progress = func(e Event) { last = e } + if tag, err := CheckOnce(context.Background(), cfg); err != nil || tag != "" || last.Stage != "done" || last.Pct != 100 { + t.Errorf("tag=%q err=%v last=%+v", tag, err, last) + } +}