mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-15 04:53:00 +08:00
506 lines
17 KiB
Go
506 lines
17 KiB
Go
package update
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"time"
|
|
|
|
"github.com/matthewlu070111/smart-srun/core/internal/domain"
|
|
)
|
|
|
|
type Device interface {
|
|
Inventory(context.Context, string) (Inventory, error)
|
|
InstalledVersions(context.Context) (map[string]string, error)
|
|
Verify(context.Context, LocalPackage) error
|
|
Precheck(context.Context, []LocalPackage) error
|
|
Install([]LocalPackage) error
|
|
Recover([]LocalPackage) error
|
|
}
|
|
|
|
type Service interface {
|
|
Stop(context.Context) error
|
|
Start(context.Context) error
|
|
Health(context.Context, string) error
|
|
}
|
|
|
|
// Begin is called inside the daemon's configuration barrier (or while an
|
|
// offline CLI holds the daemon lock). No authentication can slip between the
|
|
// idle check and the durable update gate.
|
|
func Begin(paths Paths, candidate Candidate, wasRunning bool, executable string, launch func() error) (Task, error) {
|
|
lock, err := acquireLock(paths.ControlLock())
|
|
if err != nil {
|
|
return Task{}, err
|
|
}
|
|
defer lock.Close()
|
|
if err := Guard(paths); err != nil {
|
|
return Task{}, err
|
|
}
|
|
if held, err := WorkerActive(paths); err != nil || held {
|
|
return Task{}, domain.Errorf(domain.CodeBusy, "上一更新进程仍在收尾,请稍后再试").Wrap(err)
|
|
}
|
|
if err := ValidatePlan(candidate.Plan); err != nil {
|
|
return Task{}, err
|
|
}
|
|
assets, err := RecoveryAssets(candidate.Recovery, candidate.Plan.Inventory)
|
|
if err != nil {
|
|
return Task{}, err
|
|
}
|
|
candidate.Recovery.Assets = assets
|
|
previous, err := ReadStatus(paths)
|
|
if err != nil {
|
|
return Task{}, err
|
|
}
|
|
if err := pruneCompleted(paths, previous.JobID); err != nil {
|
|
return Task{}, err
|
|
}
|
|
// Older successful workers left their executable on tmpfs. The control
|
|
// lock, absent journal and idle worker prove it is no longer needed; drop
|
|
// it before reserving space for the new atomic copy. Otherwise the first
|
|
// update after upgrading from such a version can still count two workers.
|
|
if stale, err := os.Lstat(paths.Worker()); err == nil {
|
|
if err := cleanupCompletedWorker(paths, stale); err != nil {
|
|
return Task{}, err
|
|
}
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return Task{}, storageError(err)
|
|
}
|
|
task := Task{SchemaVersion: 1, JobID: newID(), Plan: candidate.Plan, Recovery: candidate.Recovery,
|
|
Phase: "queued", CreatedAt: time.Now().UTC(), WasRunning: wasRunning, Local: candidate.Local}
|
|
journalWritten := false
|
|
defer func() {
|
|
if !journalWritten {
|
|
r := receiptFor(task)
|
|
// Preparation failures before a durable journal have not installed
|
|
// anything. Delete only this fresh task's explicitly owned files.
|
|
_ = removeOwnedFiles(paths.Temporary(task.JobID), r.TemporaryFiles, true)
|
|
_ = removeOwnedFiles(paths.Backup(task.JobID), r.BackupFiles, true)
|
|
}
|
|
}()
|
|
if err := privateDir(paths.Runtime); err != nil {
|
|
return Task{}, err
|
|
}
|
|
if err := privateDir(paths.Backup(task.JobID)); err != nil {
|
|
return Task{}, err
|
|
}
|
|
if err := checkSpace(paths, task, false); err != nil {
|
|
return Task{}, err
|
|
}
|
|
if err := copyWorker(executable, paths.Worker()); err != nil {
|
|
return Task{}, err
|
|
}
|
|
if candidate.Local {
|
|
if err := stageLocal(paths, task); err != nil {
|
|
return Task{}, err
|
|
}
|
|
}
|
|
if err := writeState(paths.Intent(), serviceIntent{task.JobID, wasRunning}); err != nil {
|
|
return Task{}, err
|
|
}
|
|
if err := writeState(paths.Journal(), task); err != nil {
|
|
// Rename may have succeeded before directory fsync failed. Never
|
|
// remove recovery inputs when even a damaged journal remains.
|
|
_, statErr := os.Lstat(paths.Journal())
|
|
journalWritten = !errors.Is(statErr, os.ErrNotExist)
|
|
return Task{}, err
|
|
}
|
|
journalWritten = true
|
|
if err := launch(); err != nil {
|
|
return task, domain.Errorf(domain.CodeRecoveryRequired, "独立更新服务未启动;原安装未改动,请运行 srunnet update recover").Wrap(err)
|
|
}
|
|
return task, nil
|
|
}
|
|
|
|
func copyWorker(source, destination string) error {
|
|
input, err := os.Open(source)
|
|
if err != nil {
|
|
return storageError(err)
|
|
}
|
|
defer input.Close()
|
|
info, err := input.Stat()
|
|
if err != nil || !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > MaxPayloadBytes {
|
|
return storageError(err)
|
|
}
|
|
output, err := os.CreateTemp(filepath.Dir(destination), ".worker-*")
|
|
if err != nil {
|
|
return storageError(err)
|
|
}
|
|
defer output.Close()
|
|
defer os.Remove(output.Name())
|
|
n, err := io.Copy(output, io.LimitReader(input, MaxPayloadBytes+1))
|
|
if err != nil || n != info.Size() {
|
|
return storageError(err)
|
|
}
|
|
if err := output.Chmod(0o700); err != nil {
|
|
return storageError(err)
|
|
}
|
|
if err := output.Sync(); err != nil {
|
|
return storageError(err)
|
|
}
|
|
if err := output.Close(); err != nil {
|
|
return storageError(err)
|
|
}
|
|
if err := os.Rename(output.Name(), destination); err != nil {
|
|
return storageError(err)
|
|
}
|
|
return syncDirectory(filepath.Dir(destination))
|
|
}
|
|
|
|
func checkSpace(paths Paths, task Task, staged bool) error {
|
|
return checkSpaceWith(paths, task, staged, FreeBytes)
|
|
}
|
|
|
|
func checkSpaceWith(paths Paths, task Task, staged bool, freeBytes func(string) (uint64, error)) error {
|
|
var oldBytes int64
|
|
for _, asset := range task.Recovery.Assets {
|
|
oldBytes += asset.Bytes
|
|
}
|
|
// Keep compressed recovery packages and private config backups on flash.
|
|
// tmpfs needs the copied worker, new packages and two unpack/work buffers.
|
|
tmpNeed := int64(MaxPayloadBytes) + task.Plan.DownloadBytes + 2*task.Plan.InstalledBytes + (8 << 20)
|
|
flashNeed := oldBytes + task.Plan.InstalledBytes + (4 << 20)
|
|
if staged {
|
|
// Begin has already copied the independent worker. Existing package
|
|
// files also already consume the free space reported by statfs. Reserve
|
|
// only the remaining allocations, retaining both unpack/work buffers.
|
|
// This accounting grants no trust: VerifyFile and native verification
|
|
// still run for every package before any installation starts.
|
|
tmpNeed -= int64(MaxPayloadBytes)
|
|
tmpNeed -= stagedBytes(packageFiles(paths.Temporary(task.JobID), task.Plan.Assets))
|
|
flashNeed -= stagedBytes(packageFiles(paths.Backup(task.JobID), task.Recovery.Assets))
|
|
}
|
|
for _, requirement := range []struct {
|
|
path string
|
|
bytes int64
|
|
}{{paths.Runtime, tmpNeed}, {paths.Config, flashNeed}} {
|
|
free, err := freeBytes(requirement.path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if free < uint64(requirement.bytes) {
|
|
return domain.Errorf(domain.CodePackageIncompatible, "更新所需的临时空间或持久恢复空间不足")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func stagedBytes(files []LocalPackage) int64 {
|
|
var bytes int64
|
|
for _, file := range files {
|
|
info, err := os.Lstat(file.Path)
|
|
if err == nil && info.Mode().IsRegular() && info.Size() == file.Asset.Bytes {
|
|
bytes += file.Asset.Bytes
|
|
}
|
|
}
|
|
return bytes
|
|
}
|
|
|
|
type Worker struct {
|
|
Paths Paths
|
|
Source ReleaseSource
|
|
Device Device
|
|
Service Service
|
|
}
|
|
|
|
func (w Worker) Run(ctx context.Context) (result error) {
|
|
lock, err := acquireLock(w.Paths.WorkerLock())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer lock.Close()
|
|
workerInfo, _ := os.Lstat(w.Paths.Worker())
|
|
defer func() {
|
|
if result == nil {
|
|
// The executable must remain available throughout a failed install
|
|
// and recovery. A completed task has no journal and no more work for
|
|
// this private copy. Unlink before releasing the worker lock so a
|
|
// subsequent task cannot replace it between identification and cleanup.
|
|
if err := cleanupCompletedWorker(w.Paths, workerInfo); err != nil {
|
|
if status, readErr := ReadStatus(w.Paths); readErr == nil {
|
|
status.Message += ";临时更新程序未能清理"
|
|
_ = writeState(w.Paths.Status(), status)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
task, err := ReadTask(w.Paths)
|
|
if err != nil {
|
|
return storageError(err)
|
|
}
|
|
if task.Phase != "queued" {
|
|
return w.fail(task, domain.Errorf(domain.CodeRecoveryRequired, "上次更新未完成;请先运行 srunnet update recover"))
|
|
}
|
|
if task.Recover {
|
|
return w.recover(task)
|
|
}
|
|
if err := w.progress(&task, "preparing", "正在核验安装环境与恢复包"); err != nil {
|
|
return err
|
|
}
|
|
if err := w.prepare(ctx, &task); err != nil {
|
|
return w.fail(task, err)
|
|
}
|
|
// Context cancellation is safe only before this durable boundary. Past it,
|
|
// worker shutdown requests never kill or abandon a native package install.
|
|
if err := ctx.Err(); err != nil {
|
|
return w.fail(task, domain.Errorf(domain.CodeCancelled, "安装前已取消更新").Wrap(err))
|
|
}
|
|
task.InstallStarted = true
|
|
if err := w.progress(&task, "installing", "正在安装,请勿断电"); err != nil {
|
|
return err
|
|
}
|
|
if err := w.Service.Stop(context.Background()); err != nil {
|
|
return w.fail(task, err)
|
|
}
|
|
files := packageFiles(w.Paths.Temporary(task.JobID), task.Plan.Assets)
|
|
// Recheck after stopping the service; no unverified cached file reaches a
|
|
// package manager, even when it was pre-staged by an explicit local install.
|
|
for _, file := range files {
|
|
if err := w.Device.Verify(context.Background(), file); err != nil {
|
|
return w.fail(task, err)
|
|
}
|
|
}
|
|
if err := w.Device.Install(files); err != nil {
|
|
return w.fail(task, err)
|
|
}
|
|
return w.finish(task, task.Plan.Release, task.Plan.Assets, "completed", "更新完成")
|
|
}
|
|
|
|
func packageFiles(directory string, assets []Asset) []LocalPackage {
|
|
files := make([]LocalPackage, 0, len(assets))
|
|
for _, asset := range assets {
|
|
files = append(files, LocalPackage{asset, filepath.Join(directory, asset.ID+"."+asset.Format)})
|
|
}
|
|
return files
|
|
}
|
|
|
|
func (w Worker) prepare(ctx context.Context, task *Task) error {
|
|
inventory, err := w.Device.Inventory(ctx, task.Plan.Inventory.DisplayVersion)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !reflect.DeepEqual(inventory, task.Plan.Inventory) {
|
|
return domain.Errorf(domain.CodeConflict, "本机包版本或环境已变化,请重新检查更新")
|
|
}
|
|
if err := checkSpace(w.Paths, *task, true); err != nil {
|
|
return err
|
|
}
|
|
if err := privateDir(w.Paths.Temporary(task.JobID)); err != nil {
|
|
return err
|
|
}
|
|
old := packageFiles(w.Paths.Backup(task.JobID), task.Recovery.Assets)
|
|
current := packageFiles(w.Paths.Temporary(task.JobID), task.Plan.Assets)
|
|
for _, file := range append(old, current...) {
|
|
if _, err := os.Lstat(file.Path); errors.Is(err, os.ErrNotExist) {
|
|
if task.Local {
|
|
return domain.Errorf(domain.CodePackageIncompatible, "本地更新缺少已校验的安装包,请重新上传")
|
|
}
|
|
if err := w.Source.Download(ctx, file.Asset, file.Path); err != nil {
|
|
return err
|
|
}
|
|
} else if err != nil {
|
|
return storageError(err)
|
|
}
|
|
if err := w.Device.Verify(ctx, file); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := w.backupConfig(*task); err != nil {
|
|
return err
|
|
}
|
|
if err := syncDirectory(w.Paths.Backup(task.JobID)); err != nil {
|
|
return err
|
|
}
|
|
task.RecoveryReady = true
|
|
if err := writeState(w.Paths.Journal(), task); err != nil {
|
|
return err
|
|
}
|
|
return w.Device.Precheck(ctx, current)
|
|
}
|
|
|
|
func (w Worker) backupConfig(task Task) error {
|
|
for _, name := range []string{"config.json", "user-presets.json"} {
|
|
data, err := readPrivate(filepath.Join(w.Paths.Config, name), 1<<20)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return storageError(err)
|
|
}
|
|
// This task gets a fresh ID, so an existing backup indicates an
|
|
// interrupted preparation. Never replace an earlier backup with new data.
|
|
path := filepath.Join(w.Paths.Backup(task.JobID), name)
|
|
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
|
if err != nil {
|
|
return storageError(err)
|
|
}
|
|
_, writeErr := file.Write(data)
|
|
if writeErr == nil {
|
|
writeErr = file.Sync()
|
|
}
|
|
closeErr := file.Close()
|
|
if writeErr != nil {
|
|
return storageError(writeErr)
|
|
}
|
|
if closeErr != nil {
|
|
return storageError(closeErr)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (w Worker) progress(task *Task, phase, message string) error {
|
|
task.Phase = phase
|
|
if err := writeState(w.Paths.Journal(), task); err != nil {
|
|
return err
|
|
}
|
|
return writeState(w.Paths.Status(), taskStatus(*task, phase, message))
|
|
}
|
|
|
|
func (w Worker) fail(task Task, cause error) error {
|
|
task.Phase = "recovery_required"
|
|
status := taskStatus(task, task.Phase, "")
|
|
status.Running, status.OK = false, false
|
|
status.Code, status.Message = ErrorStatus(cause)
|
|
status.Message += ";运行 srunnet update recover 查看并恢复"
|
|
status.RecoveryDirectory = w.Paths.Backup(task.JobID)
|
|
if task.RecoveryReady {
|
|
status.ConfigBackup = w.Paths.Backup(task.JobID)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
status.Installed, _ = w.Device.InstalledVersions(ctx)
|
|
cancel()
|
|
if err := writeState(w.Paths.Journal(), task); err != nil {
|
|
return err
|
|
}
|
|
if err := writeState(w.Paths.Status(), status); err != nil {
|
|
return err
|
|
}
|
|
return cause
|
|
}
|
|
|
|
func (w Worker) finish(task Task, version string, assets []Asset, phase, message string) error {
|
|
if err := w.progress(&task, "verifying", "正在核验安装版本与服务健康"); err != nil {
|
|
return err
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
versions, err := w.Device.InstalledVersions(ctx)
|
|
expected := make(map[string]string)
|
|
for _, asset := range assets {
|
|
expected[asset.PackageName()] = asset.PackageVersion
|
|
}
|
|
if err != nil || !maps.Equal(versions, expected) {
|
|
return w.fail(task, domain.Errorf(domain.CodeInstallFailed, "安装后的实际包版本不一致").Wrap(err))
|
|
}
|
|
if err := w.Service.Start(ctx); err != nil {
|
|
return w.fail(task, err)
|
|
}
|
|
if err := w.Service.Health(ctx, version); err != nil {
|
|
return w.fail(task, err)
|
|
}
|
|
lock, err := acquireLock(w.Paths.ControlLock())
|
|
if err != nil {
|
|
return w.fail(task, err)
|
|
}
|
|
defer lock.Close()
|
|
var intent serviceIntent
|
|
if err := readState(w.Paths.Intent(), &intent); err != nil || intent.JobID != task.JobID {
|
|
return w.fail(task, storageError(err))
|
|
}
|
|
if !intent.Running {
|
|
if err := w.Service.Stop(ctx); err != nil {
|
|
return w.fail(task, err)
|
|
}
|
|
}
|
|
status := taskStatus(task, phase, message)
|
|
status.Running, status.OK, status.Installed = false, true, versions
|
|
status.RecoveryDirectory = w.Paths.Backup(task.JobID)
|
|
if task.RecoveryReady {
|
|
status.ConfigBackup = w.Paths.Backup(task.JobID)
|
|
}
|
|
if err := writeState(w.Paths.Status(), status); err != nil {
|
|
return err
|
|
}
|
|
if err := writeState(filepath.Join(w.Paths.Backup(task.JobID), "result.json"), receiptFor(task)); err != nil {
|
|
return w.fail(task, err)
|
|
}
|
|
if err := os.Remove(w.Paths.Journal()); err != nil {
|
|
return storageError(err)
|
|
}
|
|
if err := syncDirectory(filepath.Dir(w.Paths.Journal())); err != nil {
|
|
return err
|
|
}
|
|
if err := pruneCompleted(w.Paths, task.JobID); err != nil {
|
|
// Installed versions and health have passed. Cleanup failure is not
|
|
// an installation failure; retain evidence and retry before next Begin.
|
|
status.Message += ";旧更新文件清理未完成,下次更新前会重试"
|
|
return writeState(w.Paths.Status(), status)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func QueueRecovery(paths Paths, executable string, launch func() error) (Task, error) {
|
|
lock, err := acquireLock(paths.ControlLock())
|
|
if err != nil {
|
|
return Task{}, err
|
|
}
|
|
defer lock.Close()
|
|
held, err := lockHeld(paths.WorkerLock())
|
|
if err != nil {
|
|
return Task{}, err
|
|
}
|
|
if held {
|
|
return Task{}, domain.Errorf(domain.CodeBusy, "安装仍在进行,不能中途恢复")
|
|
}
|
|
task, err := ReadTask(paths)
|
|
if err != nil {
|
|
return Task{}, storageError(err)
|
|
}
|
|
if err := copyWorker(executable, paths.Worker()); err != nil {
|
|
return Task{}, err
|
|
}
|
|
task.Recover, task.Phase, task.CreatedAt = true, "queued", time.Now().UTC()
|
|
if err := writeState(paths.Journal(), task); err != nil {
|
|
return Task{}, err
|
|
}
|
|
// Recovery reuses the job ID. Replace its previous failed snapshot before
|
|
// launching, otherwise CLI/LuCI can mistake that terminal state for the
|
|
// result of the newly queued recovery and stop waiting immediately.
|
|
if err := writeState(paths.Status(), taskStatus(task, "queued", "恢复任务已提交")); err != nil {
|
|
return Task{}, err
|
|
}
|
|
return task, launch()
|
|
}
|
|
|
|
func (w Worker) recover(task Task) error {
|
|
if !task.InstallStarted {
|
|
// Preparation did not cross the install boundary. Release the gate only
|
|
// after proving that the original package database is still intact.
|
|
return w.finish(task, task.Plan.Inventory.DisplayVersion, task.Recovery.Assets, "cancelled", "安装尚未开始,已保留原版本")
|
|
}
|
|
if !task.RecoveryReady {
|
|
return w.fail(task, domain.Errorf(domain.CodeRecoveryRequired, "恢复包尚未就绪,请使用保留的清单进行人工恢复"))
|
|
}
|
|
if err := w.progress(&task, "recovering", "正在恢复原版本安装包;配置备份保持独立"); err != nil {
|
|
return err
|
|
}
|
|
files := packageFiles(w.Paths.Backup(task.JobID), task.Recovery.Assets)
|
|
for _, file := range files {
|
|
if err := w.Device.Verify(context.Background(), file); err != nil {
|
|
return w.fail(task, err)
|
|
}
|
|
}
|
|
if err := w.Service.Stop(context.Background()); err != nil {
|
|
return w.fail(task, err)
|
|
}
|
|
if err := w.Device.Recover(files); err != nil {
|
|
return w.fail(task, err)
|
|
}
|
|
// Package recovery does not overwrite current user data with a backup.
|
|
return w.finish(task, task.Plan.Inventory.DisplayVersion, task.Recovery.Assets, "recovered", "已恢复原安装包,配置未回写;备份保留在恢复目录")
|
|
}
|