feat: update button (download, verify, stage)

This commit is contained in:
fsylvain committed 2026-10-01 18:13:45 +02:00
1 parent 834493a4f5
commit 70460e4170
41 files changed
+5345 -97

No files matched your search

@@ -0,0 +1,263 @@
package useCases
import (
"RenewCMS/internal/domain/update"
"errors"
"fmt"
"sync"
"time"
)
type StartUpdateDownloadUseCase struct {
store update.StatusStore
jobs update.JobStore
helper update.HelperDetector
fetcher update.AssetFetcher
verifier update.SignatureVerifier
runner update.StagedBinaryRunner
backup update.DatabaseBackup
staging update.StagingArea
logger update.Logger
currentVersion string
platform update.Platform
feedHost string
now func() time.Time
mu sync.Mutex
}
type StartUpdateDownloadDependencies struct {
Store update.StatusStore
Jobs update.JobStore
Helper update.HelperDetector
Fetcher update.AssetFetcher
Verifier update.SignatureVerifier
Runner update.StagedBinaryRunner
Backup update.DatabaseBackup
Staging update.StagingArea
Logger update.Logger
CurrentVersion string
Platform update.Platform
FeedURL string
Now func() time.Time
}
type jobFailure struct {
kind string
reason error
}
func (f jobFailure) Error() string {
return f.reason.Error()
}
func NewStartUpdateDownloadUseCase(dependencies StartUpdateDownloadDependencies) *StartUpdateDownloadUseCase {
return &StartUpdateDownloadUseCase{
store: dependencies.Store,
jobs: dependencies.Jobs,
helper: dependencies.Helper,
fetcher: dependencies.Fetcher,
verifier: dependencies.Verifier,
runner: dependencies.Runner,
backup: dependencies.Backup,
staging: dependencies.Staging,
logger: dependencies.Logger,
currentVersion: dependencies.CurrentVersion,
platform: dependencies.Platform,
feedHost: update.HostOf(dependencies.FeedURL),
now: dependencies.Now,
}
}
func (s *StartUpdateDownloadUseCase) Start() (update.UpdateStatus, error) {
if !s.helper.Installed() {
return update.UpdateStatus{}, update.ErrNotPrepared
}
s.mu.Lock()
defer s.mu.Unlock()
if job, found := s.jobs.Load(); found && job.Occupies() {
return update.UpdateStatus{}, update.ErrJobInProgress
}
result, found := s.store.Load()
if !found || !update.IsNewer(s.currentVersion, result.Latest) {
return update.UpdateStatus{}, update.ErrNothingToUpdate
}
binary, checksums, signature, err := s.preflight(result)
if err != nil {
s.logger.Printf("update download refused: %v", err)
return update.UpdateStatus{}, update.ErrPreflightRefused
}
job := update.Job{
Tag: result.Latest,
State: update.StateDownloading,
StartedAt: s.now().UTC(),
TotalBytes: binary.Size,
}
if err := s.jobs.Save(job); err != nil {
return update.UpdateStatus{}, err
}
status := buildUpdateStatus(s.store, s.jobs, s.currentVersion, true)
go s.run(job, binary, checksums, signature)
return status, nil
}
func (s *StartUpdateDownloadUseCase) preflight(result update.CheckResult) (binary, checksums, signature update.Asset, err error) {
names := []string{update.AssetName(result.Latest, s.platform), update.ChecksumsAssetName, update.SignatureAssetName}
found := make([]update.Asset, 0, len(names))
for _, name := range names {
asset, ok := update.FindAsset(result.Assets, name)
if !ok {
return binary, checksums, signature, fmt.Errorf("asset %q is not listed for %s", name, result.Latest)
}
if !update.IsAllowedAssetURL(asset.URL, s.feedHost) {
return binary, checksums, signature, fmt.Errorf("asset %q has a url outside the feed host or without https", name)
}
found = append(found, asset)
}
return found[0], found[1], found[2], nil
}
func (s *StartUpdateDownloadUseCase) run(job update.Job, binary, checksums, signature update.Asset) {
defer func() {
if recovered := recover(); recovered != nil {
s.fail(job, downloadFailure(fmt.Errorf("panic: %v", recovered)))
}
}()
if err := s.execute(&job, binary, checksums, signature); err != nil {
s.fail(job, err)
}
}
func (s *StartUpdateDownloadUseCase) execute(job *update.Job, binary, checksums, signature update.Asset) error {
binaryPath := s.staging.PathFor(job.Tag, binary.Name)
checksumsPath := s.staging.PathFor(job.Tag, checksums.Name)
signaturePath := s.staging.PathFor(job.Tag, signature.Name)
if err := s.staging.Prepare(job.Tag); err != nil {
return downloadFailure(err)
}
if err := s.fetchSmall(checksums, checksumsPath); err != nil {
return downloadFailure(err)
}
if err := s.fetchSmall(signature, signaturePath); err != nil {
return downloadFailure(err)
}
if err := s.fetchBinary(job, binary, binaryPath); err != nil {
return downloadFailure(err)
}
job.State = update.StateVerifying
if err := s.jobs.Save(*job); err != nil {
return downloadFailure(err)
}
if err := s.verify(job.Tag, binary.Name, binaryPath, checksumsPath, signaturePath); err != nil {
return jobFailure{kind: update.ErrorKindVerification, reason: err}
}
if _, err := s.backup.Backup(); err != nil {
return downloadFailure(err)
}
job.State = update.StateReady
job.StagedBinary = binaryPath
job.DownloadedBytes = 0
job.TotalBytes = 0
return s.jobs.Save(*job)
}
func (s *StartUpdateDownloadUseCase) fetchSmall(asset update.Asset, destination string) error {
if err := s.fetcher.Fetch(asset, destination, ignoreProgress); err != nil {
return err
}
return s.assertDeclaredSize(asset, destination)
}
func (s *StartUpdateDownloadUseCase) fetchBinary(job *update.Job, asset update.Asset, destination string) error {
lastPercent := -1
report := func(done int64, _ int64) {
current := *job
current.DownloadedBytes = done
percent := 0
if progress := current.Progress(); progress != nil {
percent = progress.Percent
}
if percent == lastPercent && done < asset.Size {
return
}
lastPercent = percent
_ = s.jobs.Save(current)
}
if err := s.fetcher.Fetch(asset, destination, report); err != nil {
return err
}
return s.assertDeclaredSize(asset, destination)
}
func ignoreProgress(int64, int64) {}
func (s *StartUpdateDownloadUseCase) assertDeclaredSize(asset update.Asset, path string) error {
size, err := s.staging.Size(path)
if err != nil {
return err
}
if asset.Size > 0 && size != asset.Size {
return fmt.Errorf("received %d bytes for %q, the release declares %d", size, asset.Name, asset.Size)
}
return nil
}
func (s *StartUpdateDownloadUseCase) verify(tag, assetName, binaryPath, checksumsPath, signaturePath string) error {
if err := s.verifier.Verify(checksumsPath, signaturePath); err != nil {
return fmt.Errorf("signature check: %w", err)
}
content, err := s.staging.ReadChecksums(checksumsPath)
if err != nil {
return err
}
expected, found := update.ChecksumFor(content, assetName)
if !found {
return fmt.Errorf("checksums.txt has no line for %q", assetName)
}
actual, err := update.FileSHA256(binaryPath)
if err != nil {
return err
}
if actual != expected {
return fmt.Errorf("sha-256 mismatch for %q", assetName)
}
if err := s.staging.MakeExecutable(binaryPath); err != nil {
return err
}
output, err := s.runner.Version(binaryPath)
if err != nil {
return fmt.Errorf("running the staged binary: %w", err)
}
if output != tag {
return fmt.Errorf("the staged binary reports version %q, expected %q", output, tag)
}
return nil
}
func downloadFailure(err error) error {
return jobFailure{kind: update.ErrorKindDownload, reason: err}
}
func (s *StartUpdateDownloadUseCase) fail(job update.Job, err error) {
kind := update.ErrorKindDownload
var failure jobFailure
if errors.As(err, &failure) {
kind = failure.kind
}
s.logger.Printf("update to %s failed (%s): %v", job.Tag, kind, err)
_ = s.staging.Remove(job.Tag)
job.State = update.StateFailed
job.Error = kind
job.DownloadedBytes = 0
job.TotalBytes = 0
job.StagedBinary = ""
if saveErr := s.jobs.Save(job); saveErr != nil {
s.logger.Printf("update job record could not be saved: %v", saveErr)
}
}