|
|
@@ -29,6 +29,15 @@ import (
|
|
|
|
|
|
|
|
|
|
|
|
const maxArchive = 300 << 20
|
|
|
|
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.
|
|
|
|
// Config says what to update and where from.
|
|
|
|
type Config struct {
|
|
|
|
type Config struct {
|
|
|
|
Server string // e.g. https://git.veillax.com
|
|
|
|
Server string // e.g. https://git.veillax.com
|
|
|
@@ -38,6 +47,8 @@ type Config struct {
|
|
|
|
// Interval between checks (default 6 hours).
|
|
|
|
// Interval between checks (default 6 hours).
|
|
|
|
Interval time.Duration
|
|
|
|
Interval time.Duration
|
|
|
|
Logf func(format string, args ...any)
|
|
|
|
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.
|
|
|
|
// The fields below exist for tests and unusual setups.
|
|
|
|
HTTP *http.Client
|
|
|
|
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
|
|
|
|
// 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).
|
|
|
|
// installed one (and Restart returned, which only happens in tests or on failure).
|
|
|
|
func CheckOnce(ctx context.Context, cfg Config) (string, error) {
|
|
|
|
func CheckOnce(ctx context.Context, cfg Config) (string, error) {
|
|
|
|
cfg.defaults()
|
|
|
|
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)
|
|
|
|
cur, ok := parseVersion(cfg.Current)
|
|
|
|
if !ok {
|
|
|
|
if !ok {
|
|
|
|
return "", fmt.Errorf("%q is not a release version", cfg.Current)
|
|
|
|
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)
|
|
|
|
rel, err := latestRelease(ctx, cfg)
|
|
|
|
if err != nil {
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if rel.Prerelease || rel.Draft {
|
|
|
|
if rel.Prerelease || rel.Draft {
|
|
|
|
|
|
|
|
cfg.say("done", 100, "Already on the newest release ("+cfg.Current+")")
|
|
|
|
return "", nil
|
|
|
|
return "", nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
latest, ok := parseVersion(rel.Tag)
|
|
|
|
latest, ok := parseVersion(rel.Tag)
|
|
|
|
if !ok || !latest.newerThan(cur) {
|
|
|
|
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
|
|
|
|
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)
|
|
|
|
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.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 {
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
}
|
|
|
@@ -155,14 +184,31 @@ func CheckOnce(ctx context.Context, cfg Config) (string, error) {
|
|
|
|
if want == "" {
|
|
|
|
if want == "" {
|
|
|
|
return "", fmt.Errorf("SHA256SUMS does not list %s", archive)
|
|
|
|
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 {
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
cfg.say("verifying", 88, "Verifying the checksum")
|
|
|
|
got := sha256.Sum256(data)
|
|
|
|
got := sha256.Sum256(data)
|
|
|
|
if hex.EncodeToString(got[:]) != want {
|
|
|
|
if hex.EncodeToString(got[:]) != want {
|
|
|
|
return "", fmt.Errorf("checksum mismatch for %s: not installing", archive)
|
|
|
|
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)
|
|
|
|
bin, err := extract(data, cfg.App)
|
|
|
|
if err != nil {
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
return "", err
|
|
|
@@ -181,15 +227,18 @@ func CheckOnce(ctx context.Context, cfg Config) (string, error) {
|
|
|
|
if err := os.WriteFile(tmp, bin, 0o755); err != nil {
|
|
|
|
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)
|
|
|
|
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 {
|
|
|
|
if err := cfg.Verify(tmp, rel.Tag); err != nil {
|
|
|
|
os.Remove(tmp)
|
|
|
|
os.Remove(tmp)
|
|
|
|
return "", fmt.Errorf("the new binary failed its check, keeping the old one: %w", err)
|
|
|
|
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
|
|
|
|
if err := os.Rename(tmp, exe); err != nil { // atomic; the running process keeps its old file
|
|
|
|
os.Remove(tmp)
|
|
|
|
os.Remove(tmp)
|
|
|
|
return "", fmt.Errorf("replacing %s: %w", exe, err)
|
|
|
|
return "", fmt.Errorf("replacing %s: %w", exe, err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
cfg.Logf("auto-update: installed %s, restarting", rel.Tag)
|
|
|
|
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 {
|
|
|
|
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)
|
|
|
|
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) {
|
|
|
|
func latestRelease(ctx context.Context, cfg Config) (release, error) {
|
|
|
|
var rel release
|
|
|
|
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 {
|
|
|
|
if err != nil {
|
|
|
|
return rel, fmt.Errorf("checking for a new release: %w", err)
|
|
|
|
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
|
|
|
|
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)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
|
|
if err != nil {
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
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 {
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
return nil, fmt.Errorf("%s: %s", url, resp.Status)
|
|
|
|
return nil, fmt.Errorf("%s: %s", url, resp.Status)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
b, err := io.ReadAll(io.LimitReader(resp.Body, max+1))
|
|
|
|
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 {
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if int64(len(b)) > max {
|
|
|
|
|
|
|
|
return nil, fmt.Errorf("%s is larger than %d bytes", url, max)
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return b, nil
|
|
|
|
return b, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|