Files
magpie/internal/plugin/bunupdate.go
T

298 lines
8.4 KiB
Go

package plugin
// Bun kept up to date, as the plugins are: every few hours magpie asks
// GitHub for Bun's newest release and, once it has been out two days (a
// release with a bad bug is usually followed by a fix within them),
// downloads it, checks its checksum, tries it and switches the plugin
// host to it, a reply streaming through the old host finishing first.
// BunVersion is the floor: magpie never runs an older Bun. The Bun it ran
// before stays on disk, and a new one the host won't start on is set
// aside for the one before, never tried again.
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/steady"
"github.com/yetone/magpie/internal/update"
)
// bunSettle is how long a Bun release is out before magpie takes it.
const bunSettle = 48 * time.Hour
// bunState is what magpie keeps about the Bun it runs, in bun/state.json.
type bunState struct {
// Current is the Bun the host runs on, "" for BunVersion.
Current string `json:"current,omitempty"`
// Previous is the one it ran on before, kept to fall back on.
Previous string `json:"previous,omitempty"`
// Bad are versions that failed their try or the host's start.
Bad []string `json:"bad,omitempty"`
Checked time.Time `json:"checked,omitzero"`
}
func bunRoot() string { return filepath.Join(filepath.Dir(catalog.CachePath()), "bun") }
func bunDirOf(v string) string { return filepath.Join(bunRoot(), v) }
func bunExeOf(v string) string { return filepath.Join(bunDirOf(v), bunExe()) }
func bunStatePath() string { return filepath.Join(bunRoot(), "state.json") }
func readBunState() bunState {
var s bunState
if b, err := steady.ReadFile(bunStatePath()); err == nil {
_ = json.Unmarshal(b, &s)
}
return s
}
func writeBunState(s bunState) error {
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(bunRoot(), 0o755); err != nil {
return err
}
return writeWhole(bunStatePath(), b)
}
func haveBun(v string) bool {
_, err := os.Stat(bunExeOf(v))
return err == nil
}
// usable is whether v may be run: no older than the floor, not set aside.
func (s bunState) usable(v string) bool {
return v != "" && !update.Newer(BunVersion, v) && !slices.Contains(s.Bad, v)
}
// BunInUse is the version of the Bun plugins run on (or will, once it is
// downloaded): the newest magpie took, else BunVersion.
func BunInUse() string {
if b := os.Getenv("MAGPIE_BUN"); b != "" {
bunMu.Lock()
defer bunMu.Unlock()
if _, ok := envBunVersion[b]; !ok {
envBunVersion[b] = bunReported(b)
}
return envBunVersion[b]
}
bunMu.Lock()
defer bunMu.Unlock()
return inUseLocked()
}
func inUseLocked() string {
s := readBunState()
if s.usable(s.Current) && haveBun(s.Current) {
return s.Current
}
return BunVersion
}
// envBunVersion is what each $MAGPIE_BUN said it is, asked once.
var envBunVersion = map[string]string{}
var bunVersionRe = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
// bunReported is the version a bun says it is, "" when it won't say.
func bunReported(exe string) string {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, err := command(ctx, exe, "--version").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// bunLatest is Bun's newest release and when it was published (tests stub it).
var bunLatest = func(ctx context.Context) (string, time.Time, error) {
b, err := getURL(ctx, "https://api.github.com/repos/oven-sh/bun/releases/latest", 4<<20)
if err != nil {
return "", time.Time{}, err
}
var r struct {
Tag string `json:"tag_name"`
Published time.Time `json:"published_at"`
Draft bool `json:"draft"`
Pre bool `json:"prerelease"`
}
if err := json.Unmarshal(b, &r); err != nil {
return "", time.Time{}, err
}
v := strings.TrimPrefix(r.Tag, "bun-v")
if r.Draft || r.Pre || !bunVersionRe.MatchString(v) {
return "", time.Time{}, fmt.Errorf("Bun's latest release is %q", r.Tag)
}
return v, r.Published, nil
}
// tryBun runs a downloaded Bun on what the host needs of it: it says its
// version, and runs a script with fetch, AsyncLocalStorage and the file
// system (tests stub it).
var tryBun = func(ctx context.Context, exe, v string) error {
if got := bunReported(exe); got != v {
return fmt.Errorf("it says it is %q", got)
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
script := `const { AsyncLocalStorage } = require("node:async_hooks"); const fs = require("node:fs"); const s = new AsyncLocalStorage();` +
`s.run(1, async () => { await Promise.resolve(); if (s.getStore() !== 1 || typeof fetch !== "function" || !fs.existsSync(process.execPath)) process.exit(3); console.log("ok") })`
out, err := command(ctx, exe, "-e", script).Output()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(ee.Stderr)))
}
return err
}
if strings.TrimSpace(string(out)) != "ok" {
return fmt.Errorf("it printed %q", strings.TrimSpace(string(out)))
}
return nil
}
// CheckBun takes Bun's newest release when it is newer than the one in
// use, out two days and not set aside: downloaded, checked, tried, then
// switched to, the host restarted gracefully. It says the version taken,
// "" when none was.
func CheckBun(ctx context.Context) (string, error) {
if os.Getenv("MAGPIE_BUN") != "" {
return "", nil
}
latest, published, err := bunLatest(ctx)
if err != nil {
return "", err
}
bunMu.Lock()
s := readBunState()
s.Checked = time.Now().UTC().Truncate(time.Second)
use := inUseLocked()
if !update.Newer(latest, use) || !s.usable(latest) || time.Since(published) < bunSettle {
err := writeBunState(s)
bunMu.Unlock()
return "", err
}
bunMu.Unlock()
exe := bunExeOf(latest)
if !haveBun(latest) {
if err := downloadBun(ctx, latest, exe); err != nil {
return "", fmt.Errorf("downloading Bun %s: %w", latest, err)
}
}
if err := tryBun(ctx, exe, latest); err != nil {
if ctx.Err() != nil {
return "", err
}
setAside(latest, fmt.Sprintf("its try failed: %s", err))
return "", fmt.Errorf("Bun %s: %w", latest, err)
}
bunMu.Lock()
s = readBunState()
if use != BunVersion || haveBun(BunVersion) {
s.Previous = use
}
s.Current = latest
s.Checked = time.Now().UTC().Truncate(time.Second)
err = writeBunState(s)
if err == nil {
pruneBuns(s)
}
bunMu.Unlock()
if err != nil {
return "", err
}
log.Printf("plugins: Bun %s → %s", use, latest)
if Running() {
Restart()
}
return latest, nil
}
// setAside marks v as not to be run again and goes back to the Bun before
// it, removing it from disk.
func setAside(v, why string) {
log.Printf("plugins: Bun %s set aside, %s", v, why)
bunMu.Lock()
defer bunMu.Unlock()
s := readBunState()
if !slices.Contains(s.Bad, v) {
s.Bad = append(s.Bad, v)
}
if s.Current == v {
s.Current, s.Previous = s.Previous, ""
}
_ = writeBunState(s)
_ = os.RemoveAll(bunDirOf(v))
}
// fallBack is the Bun to start the host on when it wouldn't start on the
// one in use: the one before, when there is one to go back to.
func fallBack() (string, bool) {
bunMu.Lock()
defer bunMu.Unlock()
s := readBunState()
if s.Current == "" || !s.usable(s.Current) {
return "", false
}
prev := s.Previous
if prev == "" {
prev = BunVersion
}
if prev == s.Current || !s.usable(prev) || !haveBun(prev) {
return "", false
}
return bunExeOf(prev), true
}
// pruneBuns removes the Buns neither in use nor kept to fall back on.
func pruneBuns(s bunState) {
keep := map[string]bool{s.Current: true, s.Previous: true}
if s.Previous == "" {
keep[BunVersion] = true
}
es, _ := os.ReadDir(bunRoot())
for _, e := range es {
if e.IsDir() && bunVersionRe.MatchString(e.Name()) && !keep[e.Name()] {
_ = os.RemoveAll(filepath.Join(bunRoot(), e.Name()))
}
}
}
// KeepBunUpdated looks for a newer Bun a little after magpie starts, then
// every updateEvery, while there are plugins to run on it.
func KeepBunUpdated(ctx context.Context) {
t := time.NewTimer(2 * time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
if len(Load().Plugins) > 0 && HasBun() {
cctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
if _, err := CheckBun(cctx); err != nil {
log.Printf("plugins: looking for a newer Bun: %s", err)
}
cancel()
}
t.Reset(updateEvery)
}
}