283 lines
8.4 KiB
Go
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)
|
|
}
|
|
}
|