Files
RenewCMS/internal/infrastructure/useCases/StartUpdateDownload.go
T

283 lines
8.4 KiB
Go

package useCases
import (
"RenewCMS/internal/domain/update"
"errors"
"fmt"
"sync"
"time"
)
type StartUpdateDownloadUseCase struct {
store update.StatusStore
jobs update.JobStore
helper update.HelperDetector
results update.ResultStore
status updateStatusBuilder
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
Results update.ResultStore
Requests update.RestartRequestStore
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,
results: dependencies.Results,
status: updateStatusBuilder{
store: dependencies.Store,
jobs: dependencies.Jobs,
results: dependencies.Results,
requests: dependencies.Requests,
currentVersion: dependencies.CurrentVersion,
now: dependencies.Now,
},
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
}
if s.status.build(true).State == update.StateRestarting {
return update.UpdateStatus{}, update.ErrRestartPending
}
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.results.Delete(); err != nil {
return update.UpdateStatus{}, err
}
if err := s.jobs.Save(job); err != nil {
return update.UpdateStatus{}, err
}
status := s.status.build(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)
}
}