3 Commits
Author SHA1 Message Date
VeillaxandClaude Sonnet 5.5 a61c79fa69 selfupdate: report progress events; reports carry update stage, percent and log
CI / test (push) Successful in 1m11s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 17:56:55 -06:00
VeillaxandClaude Sonnet 5.5 ce4baacd47 model: update requests carry an ID so a stale result can't answer a new request
CI / test (push) Successful in 5m24s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 16:58:36 -06:00
VeillaxandClaude Sonnet 5.5 cb1243e4ed model: versions and update requests for edges and sites
CI / test (push) Successful in 1m22s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 16:56:34 -06:00
3 changed files with 145 additions and 10 deletions

No files matched your search

+32
View File
@@ -39,6 +39,13 @@ type Edge struct {
OrgIDs []string `json:"orgIds"` // orgs allowed to use this edge; empty = all
IPs []string `json:"ips"` // addresses the edge reported it can bind
LastSeen time.Time `json:"lastSeen"`
// Version is the release the program reports (e.g. v0.1.5). AutoUpdate says whether it may update
// itself; only then will it act on an update request (UpdateID). UpdateMsg is the outcome of the last requested update.
Version string `json:"version,omitempty"`
AutoUpdate bool `json:"autoUpdate,omitempty"`
UpdateID string `json:"updateId,omitempty"` // set while an update requested by an administrator is pending
UpdateMsg string `json:"updateMsg,omitempty"`
}
// Site is a bamboo agent: a private network that holds the real services.
@@ -56,6 +63,13 @@ type Site struct {
TunnelIP string `json:"tunnelIp,omitempty"` // the site's address inside the tunnel network
PublicKey string `json:"publicKey,omitempty"` // WireGuard public key, reported by the agent
// Version is the release the program reports (e.g. v0.1.5). AutoUpdate says whether it may update
// itself; only then will it act on an update request (UpdateID). UpdateMsg is the outcome of the last requested update.
Version string `json:"version,omitempty"`
AutoUpdate bool `json:"autoUpdate,omitempty"`
UpdateID string `json:"updateId,omitempty"` // set while an update requested by an administrator is pending
UpdateMsg string `json:"updateMsg,omitempty"`
}
// Domain is a base domain an org owns. Hostnames of web/tls mappings must sit under one.
@@ -216,6 +230,7 @@ type EdgeConfig struct {
Local []string `json:"local"` // site IDs the edge dials directly on its own machine
AcmeEmail string `json:"acmeEmail"`
AcmeStages bool `json:"acmeStaging"`
UpdateID string `json:"updateId,omitempty"` // an administrator asked this edge to update itself now; echoed back in its report
MailEgress []MailPolicy `json:"mailEgress"`
}
@@ -289,6 +304,14 @@ type EdgeReport struct {
Stats map[string]Stat `json:"stats"` // deltas since last report
Containers []Container `json:"containers"`
MailChecks map[string]MailCheck `json:"mailChecks"` // by mail-egress ID
Version string `json:"version,omitempty"`
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.
@@ -306,6 +329,7 @@ type SiteConfig struct {
SiteID string `json:"siteId"`
TunnelIP string `json:"tunnelIp"` // this site's WireGuard address
Edges []EdgeRef `json:"edges"`
UpdateID string `json:"updateId,omitempty"` // an administrator asked this agent to update itself now; echoed back in its report
MailEgress []MailSpec `json:"mailEgress"`
}
@@ -316,6 +340,14 @@ type SiteReport struct {
MailEgress map[string]string `json:"mailEgress"` // egress ID -> "" (listening) or an error
Connected []string `json:"connected"`
Containers []Container `json:"containers"`
Version string `json:"version,omitempty"`
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.
+70 -10
View File
@@ -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
}
+43
View File
@@ -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)
}
}