initial commit [141.14.140.180,mike]
This commit is contained in:
+328
@@ -0,0 +1,328 @@
|
||||
// selfupdate.go - upd updating itself.
|
||||
//
|
||||
// The mechanism is the one dx uses: --check-update looks, --update installs,
|
||||
// and an ordinary run consults a note in the cache once a day and mentions a
|
||||
// newer release on stderr - never asking in the foreground, never asking when
|
||||
// nobody is watching. The work itself goes through upd's own machinery: the
|
||||
// same forge layer, the same retries and CA fallback, the same checksum check
|
||||
// and the same atomic install, so updating upd behaves like updating any other
|
||||
// tool it installs.
|
||||
//
|
||||
// It assumes the layout build.sh produces: one release per version, whose tag
|
||||
// is the bare number (2.0.6, a leading "v" is allowed), holding one asset
|
||||
// "upd-<goos>-<goarch>" each - that is, exactly the files from ./bin.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------ Configuration
|
||||
|
||||
var selfUpdate = selfUpdater{
|
||||
repo: "https://git.micw.org/mike/upd",
|
||||
name: "upd",
|
||||
current: version, // from main.go, set by -ldflags
|
||||
every: 24 * time.Hour,
|
||||
quietEnv: "UPD_NO_UPDATE_CHECK",
|
||||
}
|
||||
|
||||
type selfUpdater struct {
|
||||
repo string // repo URL as in the browser: https://host/owner/repo
|
||||
name string // base name of the assets, "-<goos>-<goarch>" is added
|
||||
current string // the running version
|
||||
every time.Duration // how often to look on its own; 0 turns that off
|
||||
quietEnv string // this environment variable set: keep quiet as well
|
||||
}
|
||||
|
||||
// updateRefreshFlag is the option upd calls itself with, in the background. It
|
||||
// is deliberately absent from the help.
|
||||
const updateRefreshFlag = "update-refresh"
|
||||
|
||||
// ------------------------------------------------------------ Looking by itself
|
||||
|
||||
// daily is the hook for the ordinary run. It costs nothing: in the foreground
|
||||
// the network is never touched. What comes back is the line pointing at a new
|
||||
// version - or "", when there is nothing to say. Should the note be older than
|
||||
// `every`, daily starts a background run on the side, whose answer the next
|
||||
// call will find waiting.
|
||||
func (u selfUpdater) daily() string {
|
||||
if u.every <= 0 || os.Getenv(u.quietEnv) != "" || !isTerminal(os.Stderr) {
|
||||
return ""
|
||||
}
|
||||
st := u.loadState() // no file: the zero value, hence due at once
|
||||
|
||||
if time.Since(st.Checked) >= u.every {
|
||||
// The timestamp moves on before the asking, not after: otherwise two
|
||||
// simultaneous runs start two queries, and a server that is not in the
|
||||
// mood would get a new one on every call. If the note does not stay
|
||||
// put, nothing is asked either - else an unwritable cache directory
|
||||
// would mean one process per call.
|
||||
st.Checked = time.Now()
|
||||
if u.saveState(st) == nil {
|
||||
u.spawnRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
if st.Latest == "" || compareVer(st.Latest, u.current) <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("Note: %s %s is available, run '%s --update'.", u.name, st.Latest, u.name)
|
||||
}
|
||||
|
||||
// refresh is the background run: ask, write it down, stay quiet. The writing
|
||||
// down is done by latest; if the query fails, the old state remains.
|
||||
func (u selfUpdater) refresh() {
|
||||
_, _, _ = u.latest()
|
||||
}
|
||||
|
||||
// spawnRefresh calls upd once more, only to ask, and does not wait. Without a
|
||||
// Wait the child is adopted by init when this process ends - it thus outlives
|
||||
// the call, and the call's output stays untouched by it.
|
||||
func (u selfUpdater) spawnRefresh() {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd := exec.Command(exe, "--"+updateRefreshFlag)
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil // everything to /dev/null
|
||||
if cmd.Start() == nil {
|
||||
cmd.Process.Release()
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- Note
|
||||
|
||||
// updateState is what is left between two calls: when the last question was
|
||||
// asked and what came of it.
|
||||
type updateState struct {
|
||||
Checked time.Time `json:"checked"`
|
||||
Latest string `json:"latest"`
|
||||
}
|
||||
|
||||
// The note lives in the cache directory next to the ETag cache, not in the
|
||||
// state directory: if it gets lost, the only cost is asking once too early.
|
||||
// The name cannot collide with a cache file, those are hex digests.
|
||||
func (u selfUpdater) statePath() string {
|
||||
return filepath.Join(cacheDir(), "selfupdate.json")
|
||||
}
|
||||
|
||||
func (u selfUpdater) loadState() updateState {
|
||||
var st updateState
|
||||
readJSON(u.statePath(), &st) // a missing or broken file counts as none
|
||||
return st
|
||||
}
|
||||
|
||||
func (u selfUpdater) saveState(st updateState) error {
|
||||
if err := os.MkdirAll(cacheDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(u.statePath(), st)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ The work
|
||||
|
||||
// check only looks and touches nothing.
|
||||
func (u selfUpdater) check(w io.Writer) error {
|
||||
_, rel, err := u.latest()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if compareVer(rel.TagName, u.current) <= 0 {
|
||||
fmt.Fprintf(w, "%s %s is up to date\n", u.name, u.current)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(w, "%s %s is available, running %s\n", u.name, rel.TagName, u.current)
|
||||
if rel.HTMLURL != "" {
|
||||
fmt.Fprintf(w, " %s\n", rel.HTMLURL)
|
||||
}
|
||||
fmt.Fprintf(w, " run '%s --update' to install it\n", u.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// install fetches the newest release and replaces the running file with it.
|
||||
func (u selfUpdater) install(w io.Writer) error {
|
||||
c, rel, err := u.latest()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if compareVer(rel.TagName, u.current) <= 0 && !opt.force {
|
||||
fmt.Fprintf(w, "%s %s is up to date\n", u.name, u.current)
|
||||
return nil
|
||||
}
|
||||
|
||||
want := fmt.Sprintf("%s-%s-%s", u.name, runtime.GOOS, runtime.GOARCH)
|
||||
var src *Asset
|
||||
for i := range rel.Assets {
|
||||
if rel.Assets[i].Name == want {
|
||||
src = &rel.Assets[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if src == nil {
|
||||
var have []string
|
||||
for _, a := range rel.Assets {
|
||||
have = append(have, a.Name)
|
||||
}
|
||||
return fmt.Errorf("release %s has no %q (only %s)", rel.TagName, want, strings.Join(have, ", "))
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot locate the running binary: %w", err)
|
||||
}
|
||||
// An installed upd may be a symlink into ./bin. What should be replaced is
|
||||
// the file behind it, not the link.
|
||||
if real, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
exe = real
|
||||
}
|
||||
// Ask before the download, not after it: a missing write permission ought
|
||||
// to show up before a few megabytes have gone down the wire.
|
||||
if dir := filepath.Dir(exe); !writable(dir) {
|
||||
return fmt.Errorf("no write permission in %s (use sudo, or install upd elsewhere)", dir)
|
||||
}
|
||||
|
||||
if opt.dryRun {
|
||||
fmt.Fprintf(w, "[dry-run] would install %s %s to %s\n", want, rel.TagName, exe)
|
||||
return nil
|
||||
}
|
||||
|
||||
tmpdir, err := os.MkdirTemp("", "upd-self-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
dl := filepath.Join(tmpdir, want)
|
||||
dlURL, dlHdr := c.assetURL(src)
|
||||
hdr := c.authHeaders()
|
||||
for k, v := range dlHdr {
|
||||
hdr[k] = v
|
||||
}
|
||||
fmt.Fprintf(w, "Downloading %s %s (%s) ...\n", want, rel.TagName, humanSize(src.Size))
|
||||
if err := downloadTo(dl, dlURL, hdr, src.Size); err != nil {
|
||||
return err
|
||||
}
|
||||
if st, err := os.Stat(dl); err == nil && src.Size > 0 && st.Size() < src.Size {
|
||||
return fmt.Errorf("incomplete download: %d of %d bytes", st.Size(), src.Size)
|
||||
}
|
||||
if err := verifyDownload(c, dl, src, rel.Assets, tmpdir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(dl, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.probe(dl, rel.TagName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := installAtomic(dl, exe); err != nil {
|
||||
return err
|
||||
}
|
||||
stripQuarantine(exe)
|
||||
u.saveState(updateState{Checked: time.Now(), Latest: rel.TagName})
|
||||
|
||||
fmt.Fprintf(w, "%s %s -> %s, at %s\n", u.name, u.current, rel.TagName, exe)
|
||||
return nil
|
||||
}
|
||||
|
||||
// probe runs the freshly fetched binary once. That catches a file that is
|
||||
// truncated, built for the wrong platform, or not executable in the first
|
||||
// place, before it replaces the running one. The version flag is the built-in
|
||||
// one, not spec.versionFlag(): what upd answers to is not the user's to
|
||||
// configure here.
|
||||
func (u selfUpdater) probe(path, tag string) error {
|
||||
got := installedVersion(path, &Spec{VersionFlag: "--version"})
|
||||
if got == "" {
|
||||
return fmt.Errorf("the downloaded binary does not run, %s left alone", u.name)
|
||||
}
|
||||
if got != normVer(tag) {
|
||||
return fmt.Errorf("the downloaded binary reports %s, expected %s", got, normVer(tag))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// latest asks the forge for the newest release, through the same code path an
|
||||
// ordinary run uses, and notes down what came back.
|
||||
func (u selfUpdater) latest() (*forgeCtx, *Release, error) {
|
||||
spec := &Spec{Repo: u.repo}
|
||||
c, err := newForgeCtx(spec)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
rel, _, err := c.fetchRelease(spec)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if rel == nil { // --list on the command line: fetchRelease returns the list
|
||||
return nil, nil, fmt.Errorf("--list cannot be combined with --update")
|
||||
}
|
||||
|
||||
// Every question that succeeds fills the note - no matter whether it came
|
||||
// from --update, from --check-update or from the background run.
|
||||
u.saveState(updateState{Checked: time.Now(), Latest: rel.TagName})
|
||||
return c, rel, nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Numbers
|
||||
|
||||
// compareVer compares two versions component by component, numerically, so
|
||||
// that 2.0.10 lands behind 2.0.9 and not in front of it. A leading "v" does not
|
||||
// count, missing places count as 0 (2.1 == 2.1.0), and a suffix on the number
|
||||
// makes the version older, not newer (2.0.6-rc1 < 2.0.6). The result is the one
|
||||
// of strings.Compare: -1, 0, 1.
|
||||
func compareVer(a, b string) int {
|
||||
as := strings.Split(normVer(a), ".")
|
||||
bs := strings.Split(normVer(b), ".")
|
||||
|
||||
for i := 0; i < len(as) || i < len(bs); i++ {
|
||||
x, y := "0", "0"
|
||||
if i < len(as) {
|
||||
x = as[i]
|
||||
}
|
||||
if i < len(bs) {
|
||||
y = bs[i]
|
||||
}
|
||||
if c := compareVerPart(x, y); c != 0 {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func compareVerPart(a, b string) int {
|
||||
na, ra := splitNum(a)
|
||||
nb, rb := splitNum(b)
|
||||
switch {
|
||||
case na != nb:
|
||||
if na < nb {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
case ra == rb:
|
||||
return 0
|
||||
case ra == "": // 2.0.6 is finished, 2.0.6-rc1 is not yet
|
||||
return 1
|
||||
case rb == "":
|
||||
return -1
|
||||
}
|
||||
return strings.Compare(ra, rb)
|
||||
}
|
||||
|
||||
// splitNum separates "10-rc1" into 10 and "-rc1".
|
||||
func splitNum(s string) (int, string) {
|
||||
i := 0
|
||||
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
n, _ := strconv.Atoi(s[:i])
|
||||
return n, s[i:]
|
||||
}
|
||||
Reference in New Issue
Block a user