[mike@maginot]
This commit is contained in:
+88
-89
@@ -1,17 +1,17 @@
|
||||
// selfupdate.go — Selbstaktualisierung aus den Releases einer Gitea-Instanz.
|
||||
// selfupdate.go — updating oneself from the releases of a Gitea instance.
|
||||
//
|
||||
// Die Datei ist als Kopiervorlage gedacht: in ein anderes Programm übernehmen,
|
||||
// den Konfigurationsblock unten anpassen, `--update` und `--check-update` in
|
||||
// die Optionen hängen — fertig. Sie braucht nur die Standardbibliothek und
|
||||
// bringt außer dem Block keine Namen mit, die nicht mit "selfUpdate" oder
|
||||
// "update" beginnen.
|
||||
// The file is meant to be copied: take it into another program, adjust the
|
||||
// configuration block below, hang `--update` and `--check-update` into the
|
||||
// options — done. It needs nothing but the standard library, and apart from
|
||||
// that block it brings no names that do not begin with "selfUpdate" or
|
||||
// "update".
|
||||
//
|
||||
// Vorausgesetzt wird das Ablageschema von build.sh: je Version ein Release,
|
||||
// dessen Tag die nackte Nummer ist (2.1.6, ein führendes "v" ist erlaubt), und
|
||||
// darin je ein Asset "<name>-<goos>-<goarch>" — also genau die Dateien aus
|
||||
// ./bin. Gitea liefert unter /api/v1/repos/<owner>/<repo>/releases/latest das
|
||||
// neueste Release ohne Entwurf und ohne Vorabversion; GitHub spricht dieselbe
|
||||
// Route mit anderen Feldnamen und ist deshalb nicht mitgemeint.
|
||||
// It assumes the layout build.sh produces: one release per version, whose tag
|
||||
// is the bare number (2.1.6, a leading "v" is allowed), holding one asset
|
||||
// "<name>-<goos>-<goarch>" each — that is, exactly the files from ./bin. Under
|
||||
// /api/v1/repos/<owner>/<repo>/releases/latest Gitea hands out the newest
|
||||
// release that is neither a draft nor a prerelease; GitHub speaks the same
|
||||
// route with different field names and is therefore not covered.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -31,50 +31,50 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------ Konfiguration
|
||||
// ------------------------------------------------------------ Configuration
|
||||
|
||||
var selfUpdate = selfUpdater{
|
||||
repo: "https://git.micw.org/mike/dx",
|
||||
asset: "dx",
|
||||
current: version, // aus main.go, per -ldflags gesetzt
|
||||
current: version, // from main.go, set by -ldflags
|
||||
verify: []string{"--version"},
|
||||
every: 24 * time.Hour,
|
||||
quietEnv: "DX_NO_UPDATE_CHECK",
|
||||
}
|
||||
|
||||
type selfUpdater struct {
|
||||
repo string // Repo-URL wie im Browser: https://host/owner/repo
|
||||
asset string // Basisname der Assets, "-<goos>-<goarch>" kommt dazu
|
||||
current string // laufende Version
|
||||
verify []string // Probelauf des Downloads; leer lässt ihn ausfallen
|
||||
every time.Duration // Abstand der Nachschau von selbst; 0 schaltet sie ab
|
||||
quietEnv string // diese Umgebungsvariable gesetzt: auch dann Ruhe
|
||||
repo string // repo URL as in the browser: https://host/owner/repo
|
||||
asset string // base name of the assets, "-<goos>-<goarch>" is added
|
||||
current string // the running version
|
||||
verify []string // trial run of the download; empty skips it
|
||||
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 ist die Option, mit der das Programm sich selbst im
|
||||
// Hintergrund aufruft. Sie steht bewusst nicht in der Hilfe.
|
||||
// updateRefreshFlag is the option the program calls itself with, in the
|
||||
// background. It is deliberately absent from the help.
|
||||
const updateRefreshFlag = "--update-refresh"
|
||||
|
||||
// ------------------------------------------------------- Nachschau von selbst
|
||||
// ------------------------------------------------------------ Looking by itself
|
||||
|
||||
// daily ist der Anschluss für den gewöhnlichen Programmlauf. Sie kostet nichts:
|
||||
// im Vordergrund wird nie das Netz angefasst. Zurück kommt die Zeile, die auf
|
||||
// eine neue Version hinweist — oder "", wenn es nichts zu sagen gibt; wie sie
|
||||
// aussieht, entscheidet der Aufrufer. Ist der Merkzettel älter als `every`,
|
||||
// stößt sie nebenbei einen Hintergrundlauf an, dessen Antwort der nächste
|
||||
// Aufruf vorfindet.
|
||||
// daily is the hook for the ordinary run of the program. 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; what it
|
||||
// looks like is up to the caller. 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) != "" || !updateOnTerminal() {
|
||||
return ""
|
||||
}
|
||||
st := u.loadState() // keine Datei: Nullwert, also sofort fällig
|
||||
st := u.loadState() // no file: the zero value, hence due at once
|
||||
|
||||
if time.Since(st.Checked) >= u.every {
|
||||
// Der Zeitstempel wandert weiter, bevor gefragt wird, nicht danach:
|
||||
// sonst starten zwei gleichzeitige Läufe zwei Abfragen, und ein Server,
|
||||
// der gerade nicht mag, bekäme bei jedem Aufruf eine neue. Bleibt der
|
||||
// Merkzettel nicht liegen, wird auch nicht gefragt — sonst hinge an
|
||||
// einem unbeschreibbaren Cache-Verzeichnis ein Prozess je Aufruf.
|
||||
// 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()
|
||||
@@ -87,46 +87,45 @@ func (u selfUpdater) daily() string {
|
||||
return fmt.Sprintf("%s %s is available, run '%s --update'", u.asset, st.Latest, u.asset)
|
||||
}
|
||||
|
||||
// refresh ist der Hintergrundlauf: fragen, notieren, still bleiben. Das
|
||||
// Notieren erledigt latest, misslingt die Abfrage, bleibt der alte Stand.
|
||||
// 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 ruft dieses Programm noch einmal auf, nur zum Fragen, und wartet
|
||||
// nicht. Ohne Wait wird das Kind beim Ende dieses Prozesses von init
|
||||
// übernommen — es lebt also länger als der Aufruf, und dessen Ausgabe bleibt
|
||||
// davon unberührt.
|
||||
// spawnRefresh calls this program 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 // alles nach /dev/null
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil // everything to /dev/null
|
||||
if cmd.Start() == nil {
|
||||
cmd.Process.Release()
|
||||
}
|
||||
}
|
||||
|
||||
// Der Hinweis gilt dem Menschen davor. Läuft das Programm in einer Pipe, in
|
||||
// einem Skript oder unter cron, wird weder gefragt noch etwas gesagt.
|
||||
// The hint is meant for the person sitting there. Running in a pipe, in a
|
||||
// script or under cron, the program neither asks nor says anything.
|
||||
func updateOnTerminal() bool {
|
||||
st, err := os.Stderr.Stat()
|
||||
return err == nil && st.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- Merkzettel
|
||||
// --------------------------------------------------------------------- Note
|
||||
|
||||
// updateState ist das, was zwischen zwei Aufrufen übrig bleibt: wann zuletzt
|
||||
// gefragt wurde und was dabei herauskam.
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// Der Merkzettel liegt im Cache-Verzeichnis, nicht in der Konfiguration: geht
|
||||
// er verloren, wird eben einmal zu früh gefragt.
|
||||
// The note lives in the cache directory, not in the configuration: if it gets
|
||||
// lost, the only cost is asking once too early.
|
||||
func (u selfUpdater) statePath() (string, error) {
|
||||
dir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
@@ -145,7 +144,7 @@ func (u selfUpdater) loadState() updateState {
|
||||
if err != nil {
|
||||
return st
|
||||
}
|
||||
json.Unmarshal(b, &st) // eine kaputte Datei zählt wie keine
|
||||
json.Unmarshal(b, &st) // a broken file counts as none
|
||||
return st
|
||||
}
|
||||
|
||||
@@ -161,8 +160,8 @@ func (u selfUpdater) saveState(st updateState) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Über eine Nebendatei, damit ein gleichzeitiger Lauf nie ein halbes JSON
|
||||
// vorfindet.
|
||||
// By way of a file alongside, so that a simultaneous run never comes upon
|
||||
// half a JSON.
|
||||
tmp := path + ".new"
|
||||
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||
return err
|
||||
@@ -174,9 +173,9 @@ func (u selfUpdater) saveState(st updateState) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Ablauf
|
||||
// ------------------------------------------------------------------ The work
|
||||
|
||||
// check sieht nur nach und fasst nichts an.
|
||||
// check only looks and touches nothing.
|
||||
func (u selfUpdater) check(w io.Writer) error {
|
||||
rel, err := u.latest()
|
||||
if err != nil {
|
||||
@@ -191,7 +190,7 @@ func (u selfUpdater) check(w io.Writer) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// install holt das neueste Release und ersetzt die laufende Datei damit.
|
||||
// install fetches the newest release and replaces the running file with it.
|
||||
func (u selfUpdater) install(w io.Writer) error {
|
||||
rel, err := u.latest()
|
||||
if err != nil {
|
||||
@@ -222,8 +221,8 @@ func (u selfUpdater) install(w io.Writer) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot locate the running binary: %w", err)
|
||||
}
|
||||
// Ein installiertes dx ist oft ein Symlink nach ./bin. Ersetzt werden soll
|
||||
// die Datei dahinter, nicht der Link.
|
||||
// An installed dx is often 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
|
||||
}
|
||||
@@ -237,7 +236,7 @@ func (u selfUpdater) install(w io.Writer) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp) // greift nur, wenn das Umbenennen unten ausfällt
|
||||
defer os.Remove(tmp) // only bites when the renaming below falls through
|
||||
|
||||
if err := u.probe(tmp, rel.TagName); err != nil {
|
||||
return err
|
||||
@@ -251,14 +250,14 @@ func (u selfUpdater) install(w io.Writer) error {
|
||||
}
|
||||
|
||||
func (u selfUpdater) download(a *updateAsset, exe string, mode os.FileMode) (string, error) {
|
||||
// Die neue Datei entsteht neben der alten: dasselbe Dateisystem, also ist
|
||||
// das Umbenennen am Ende ein atomarer Schritt und kein halber Kopiervorgang.
|
||||
// Sie entsteht auch vor dem ersten Byte — ein fehlendes Schreibrecht soll
|
||||
// auffallen, bevor ein paar Megabyte durch die Leitung sind.
|
||||
// The new file comes into being next to the old one: same filesystem, so
|
||||
// the renaming at the end is one atomic step and not half a copy. It also
|
||||
// comes into being before the first byte — a missing write permission ought
|
||||
// to show up before a few megabytes have gone down the wire.
|
||||
dir := filepath.Dir(exe)
|
||||
f, err := os.CreateTemp(dir, "."+filepath.Base(exe)+".new")
|
||||
if err != nil {
|
||||
var pe *os.PathError // der Pfad steht schon in der Meldung
|
||||
var pe *os.PathError // the path is in the message already
|
||||
if errors.As(err, &pe) {
|
||||
err = pe.Err
|
||||
}
|
||||
@@ -291,9 +290,9 @@ func (u selfUpdater) download(a *updateAsset, exe string, mode os.FileMode) (str
|
||||
return tmp, nil
|
||||
}
|
||||
|
||||
// probe ruft den frisch geladenen Läufer einmal auf. Das fängt eine
|
||||
// abgeschnittene, für die falsche Plattform gebaute oder gar nicht erst
|
||||
// ausführbare Datei ab, bevor sie die laufende ersetzt.
|
||||
// probe calls the freshly fetched runner 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.
|
||||
func (u selfUpdater) probe(path, tag string) error {
|
||||
if len(u.verify) == 0 {
|
||||
return nil
|
||||
@@ -312,21 +311,22 @@ func (u selfUpdater) probe(path, tag string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateReplace tauscht die laufende Datei gegen die neue.
|
||||
// updateReplace swaps the running file for the new one.
|
||||
func updateReplace(tmp, exe string) error {
|
||||
if err := os.Rename(tmp, exe); err == nil {
|
||||
return nil
|
||||
}
|
||||
// Unix überschreibt die Datei eines laufenden Programms klaglos, Windows
|
||||
// nicht: dort muss die alte erst aus dem Weg. Löschen lässt sie sich
|
||||
// frühestens, wenn dieser Prozess endet — das Aufräumen darf also scheitern.
|
||||
// Unix overwrites the file of a running program without complaint, Windows
|
||||
// does not: there the old one has to be got out of the way first. Deleting
|
||||
// it becomes possible when this process ends at the earliest — so the
|
||||
// tidying up is allowed to fail.
|
||||
old := exe + ".old"
|
||||
os.Remove(old)
|
||||
if err := os.Rename(exe, old); err != nil {
|
||||
return fmt.Errorf("cannot replace %s: %w", exe, err)
|
||||
}
|
||||
if err := os.Rename(tmp, exe); err != nil {
|
||||
os.Rename(old, exe) // zurück auf den alten Stand
|
||||
os.Rename(old, exe) // back to how it was
|
||||
return fmt.Errorf("cannot replace %s: %w", exe, err)
|
||||
}
|
||||
os.Remove(old)
|
||||
@@ -352,8 +352,8 @@ func (u selfUpdater) latest() (updateRelease, error) {
|
||||
if err != nil {
|
||||
return updateRelease{}, err
|
||||
}
|
||||
// Die Frage ist klein; hängt sie, hängt sie nicht lange. Das großzügige
|
||||
// Zeitlimit von updateClient gilt dem Download.
|
||||
// The question is a small one; if it hangs, it does not hang for long. The
|
||||
// generous time limit of updateClient is meant for the download.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -371,13 +371,13 @@ func (u selfUpdater) latest() (updateRelease, error) {
|
||||
return updateRelease{}, fmt.Errorf("%s has no releases", u.repo)
|
||||
}
|
||||
|
||||
// Jede geglückte Frage füllt den Merkzettel — egal ob sie von --update,
|
||||
// --check-update oder dem Hintergrundlauf kam.
|
||||
// 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 rel, nil
|
||||
}
|
||||
|
||||
// apiBase macht aus https://host/owner/repo die API-Wurzel des Repos.
|
||||
// apiBase turns https://host/owner/repo into the API root of the repo.
|
||||
func (u selfUpdater) apiBase() (string, error) {
|
||||
bad := fmt.Errorf("repo %q: expected https://host/owner/repo", u.repo)
|
||||
|
||||
@@ -392,9 +392,8 @@ func (u selfUpdater) apiBase() (string, error) {
|
||||
return fmt.Sprintf("%s://%s/api/v1/repos/%s/%s", ref.Scheme, ref.Host, parts[0], parts[1]), nil
|
||||
}
|
||||
|
||||
// Ein Zeitlimit für alles zusammen: die Suche kostet ein paar hundert
|
||||
// Millisekunden, der Download ein paar Megabyte — beides darf hängen bleiben,
|
||||
// aber nicht ewig.
|
||||
// One time limit for all of it: the look costs a few hundred milliseconds, the
|
||||
// download a few megabytes — both may hang, but not forever.
|
||||
var updateClient = &http.Client{Timeout: 5 * time.Minute}
|
||||
|
||||
func updateGet(ctx context.Context, target string) (*http.Response, error) {
|
||||
@@ -415,13 +414,13 @@ func updateGet(ctx context.Context, target string) (*http.Response, error) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Nummern
|
||||
// ------------------------------------------------------------------ Numbers
|
||||
|
||||
// updateCompare vergleicht zwei Versionen komponentenweise numerisch, damit
|
||||
// 2.1.10 hinter 2.1.9 landet und nicht davor. Ein führendes "v" zählt nicht,
|
||||
// fehlende Stellen gelten als 0 (2.1 == 2.1.0), und ein Suffix am Zahlenrest
|
||||
// macht die Version älter, nicht neuer (2.1.6-rc1 < 2.1.6). Ergebnis wie bei
|
||||
// strings.Compare: -1, 0, 1.
|
||||
// updateCompare compares two versions component by component, numerically, so
|
||||
// that 2.1.10 lands behind 2.1.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.1.6-rc1 < 2.1.6). The result is the one
|
||||
// of strings.Compare: -1, 0, 1.
|
||||
func updateCompare(a, b string) int {
|
||||
as := strings.Split(strings.TrimPrefix(a, "v"), ".")
|
||||
bs := strings.Split(strings.TrimPrefix(b, "v"), ".")
|
||||
@@ -452,7 +451,7 @@ func updateComparePart(a, b string) int {
|
||||
return 1
|
||||
case ra == rb:
|
||||
return 0
|
||||
case ra == "": // 2.1.6 ist fertig, 2.1.6-rc1 noch nicht
|
||||
case ra == "": // 2.1.6 is finished, 2.1.6-rc1 is not yet
|
||||
return 1
|
||||
case rb == "":
|
||||
return -1
|
||||
@@ -460,7 +459,7 @@ func updateComparePart(a, b string) int {
|
||||
return strings.Compare(ra, rb)
|
||||
}
|
||||
|
||||
// updateSplitNum trennt "10-rc1" in 10 und "-rc1".
|
||||
// updateSplitNum separates "10-rc1" into 10 and "-rc1".
|
||||
func updateSplitNum(s string) (int, string) {
|
||||
i := 0
|
||||
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
||||
@@ -470,8 +469,8 @@ func updateSplitNum(s string) (int, string) {
|
||||
return n, s[i:]
|
||||
}
|
||||
|
||||
// updateSize ist bewusst eine eigene kleine Formatierung und nicht formSize aus
|
||||
// main.go — die Datei soll für sich alleine stehen.
|
||||
// updateSize is deliberately a small formatting of its own and not formSize
|
||||
// from main.go — the file is meant to stand on its own.
|
||||
func updateSize(b int64) string {
|
||||
const k = 1024
|
||||
switch {
|
||||
|
||||
Reference in New Issue
Block a user