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

+9
View File
@@ -19,17 +19,25 @@ type ReleaseNotes struct {
Items []string `json:"items"`
}
type Asset struct {
Name string `json:"name"`
URL string `json:"url"`
Size int64 `json:"size"`
}
type Release struct {
Tag string
URL string
PublishedAt time.Time
Notes ReleaseNotes
Assets []Asset
}
type CheckResult struct {
Latest string `json:"latest"`
ReleaseURL string `json:"release_url"`
Notes ReleaseNotes `json:"notes"`
Assets []Asset `json:"assets"`
CheckedAt time.Time `json:"checked_at"`
}
@@ -58,6 +66,7 @@ func NewResult(release Release, checkedAt time.Time) CheckResult {
Latest: release.Tag,
ReleaseURL: release.URL,
Notes: release.Notes,
Assets: release.Assets,
CheckedAt: checkedAt.UTC(),
}
}
+177
View File
@@ -0,0 +1,177 @@
package update
import (
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"net/url"
"os"
"strings"
"time"
)
const (
StateDownloading = "downloading"
StateVerifying = "verifying"
StateReady = "ready"
StateFailed = "failed"
ErrorKindDownload = "download"
ErrorKindVerification = "verification"
ChecksumsAssetName = "checksums.txt"
SignatureAssetName = "checksums.txt.sig"
windowsOS = "windows"
)
var (
ErrNotPrepared = errors.New("this server cannot apply updates")
ErrJobInProgress = errors.New("an update job is already running or ready")
ErrNothingToUpdate = errors.New("there is nothing to update")
ErrPreflightRefused = errors.New("the update cannot be downloaded")
)
type Platform struct {
OS string
Arch string
}
type Job struct {
Tag string `json:"tag"`
State string `json:"state"`
Error string `json:"error"`
StartedAt time.Time `json:"started_at"`
DownloadedBytes int64 `json:"downloaded_bytes"`
TotalBytes int64 `json:"total_bytes"`
StagedBinary string `json:"staged_binary"`
}
type ProgressFunc func(done int64, total int64)
type AssetFetcher interface {
Fetch(asset Asset, destination string, report ProgressFunc) error
}
type SignatureVerifier interface {
Verify(checksumsPath string, signaturePath string) error
}
type StagedBinaryRunner interface {
Version(binaryPath string) (string, error)
}
type DatabaseBackup interface {
Backup() (string, error)
}
type StagingArea interface {
Prepare(tag string) error
PathFor(tag string, name string) string
Size(path string) (int64, error)
ReadChecksums(path string) (string, error)
MakeExecutable(path string) error
Remove(tag string) error
}
type HelperDetector interface {
Installed() bool
}
type JobStore interface {
Load() (Job, bool)
Save(job Job) error
}
type Logger interface {
Printf(format string, args ...any)
}
func (j Job) IsRunning() bool {
return j.State == StateDownloading || j.State == StateVerifying
}
func (j Job) Occupies() bool {
return j.IsRunning() || j.State == StateReady
}
func (j Job) Progress() *Progress {
if j.State != StateDownloading || j.TotalBytes <= 0 {
return nil
}
return &Progress{
Percent: int(j.DownloadedBytes * 100 / j.TotalBytes),
DownloadedBytes: j.DownloadedBytes,
TotalBytes: j.TotalBytes,
}
}
func (s UpdateStatus) WithJob(job Job) UpdateStatus {
s.State = job.State
s.Progress = job.Progress()
s.Error = nil
if job.Error != "" {
kind := job.Error
s.Error = &kind
}
startedAt := job.StartedAt
s.StartedAt = &startedAt
return s
}
func AssetName(tag string, platform Platform) string {
name := "renewcms_" + tag + "_" + platform.OS + "_" + platform.Arch
if platform.OS == windowsOS {
name += ".exe"
}
return name
}
func FindAsset(assets []Asset, name string) (Asset, bool) {
for _, asset := range assets {
if asset.Name == name {
return asset, true
}
}
return Asset{}, false
}
func HostOf(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil {
return ""
}
return parsed.Host
}
func IsAllowedAssetURL(rawURL string, allowedHost string) bool {
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Scheme != "https" {
return false
}
return allowedHost != "" && parsed.Host == allowedHost
}
func ChecksumFor(checksums string, assetName string) (string, bool) {
for _, line := range strings.Split(checksums, "\n") {
fields := strings.Fields(line)
if len(fields) == 2 && strings.TrimPrefix(fields[1], "*") == assetName {
return strings.ToLower(fields[0]), true
}
}
return "", false
}
func FileSHA256(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
@@ -0,0 +1,44 @@
package update
import (
domain "RenewCMS/internal/domain/update"
"crypto/ed25519"
"encoding/base64"
"errors"
"os"
"strings"
)
var (
errSignatureEncoding = errors.New("the signature is not a base64 encoded ed25519 signature")
errSignatureMismatch = errors.New("the signature does not match the checksums file")
)
type Ed25519SignatureVerifier struct {
key ed25519.PublicKey
}
var _ domain.SignatureVerifier = &Ed25519SignatureVerifier{}
func NewEd25519SignatureVerifier(key ed25519.PublicKey) *Ed25519SignatureVerifier {
return &Ed25519SignatureVerifier{key: key}
}
func (v *Ed25519SignatureVerifier) Verify(checksumsPath string, signaturePath string) error {
content, err := os.ReadFile(checksumsPath)
if err != nil {
return err
}
rawSignature, err := os.ReadFile(signaturePath)
if err != nil {
return err
}
signature, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(rawSignature)))
if err != nil || len(signature) != ed25519.SignatureSize {
return errSignatureEncoding
}
if len(v.key) != ed25519.PublicKeySize || !ed25519.Verify(v.key, content, signature) {
return errSignatureMismatch
}
return nil
}
@@ -0,0 +1,29 @@
package update
import (
domain "RenewCMS/internal/domain/update"
"context"
"os/exec"
"strings"
"time"
)
const stagedBinaryRunTimeout = 15 * time.Second
type ExecStagedBinaryRunner struct{}
var _ domain.StagedBinaryRunner = &ExecStagedBinaryRunner{}
func NewExecStagedBinaryRunner() *ExecStagedBinaryRunner {
return &ExecStagedBinaryRunner{}
}
func (r *ExecStagedBinaryRunner) Version(binaryPath string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), stagedBinaryRunTimeout)
defer cancel()
output, err := exec.CommandContext(ctx, binaryPath, "--version").Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(output)), nil
}
@@ -0,0 +1,26 @@
package update
import (
domain "RenewCMS/internal/domain/update"
"encoding/json"
"os"
)
type FileHelperDetector struct {
statusPath string
}
var _ domain.HelperDetector = &FileHelperDetector{}
func NewFileHelperDetector(statusPath string) *FileHelperDetector {
return &FileHelperDetector{statusPath: statusPath}
}
func (d *FileHelperDetector) Installed() bool {
content, err := os.ReadFile(d.statusPath)
if err != nil {
return false
}
var status map[string]any
return json.Unmarshal(content, &status) == nil && status != nil
}
@@ -24,11 +24,18 @@ type GiteaReleaseFeed struct {
var _ domain.ReleaseFeed = &GiteaReleaseFeed{}
type giteaRelease struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Body string `json:"body"`
Draft bool `json:"draft"`
PublishedAt time.Time `json:"published_at"`
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Body string `json:"body"`
Draft bool `json:"draft"`
PublishedAt time.Time `json:"published_at"`
Assets []giteaAsset `json:"assets"`
}
type giteaAsset struct {
Name string `json:"name"`
Size int64 `json:"size"`
DownloadURL string `json:"browser_download_url"`
}
func NewGiteaReleaseFeed(baseURL string) *GiteaReleaseFeed {
@@ -73,7 +80,16 @@ func toReleases(raw []giteaRelease) []domain.Release {
URL: item.HTMLURL,
PublishedAt: item.PublishedAt,
Notes: domain.BuildNotes(item.TagName, item.PublishedAt, item.Body),
Assets: toAssets(item.Assets),
})
}
return releases
}
func toAssets(raw []giteaAsset) []domain.Asset {
assets := make([]domain.Asset, 0, len(raw))
for _, item := range raw {
assets = append(assets, domain.Asset{Name: item.Name, URL: item.DownloadURL, Size: item.Size})
}
return assets
}
@@ -0,0 +1,185 @@
package update
import (
domain "RenewCMS/internal/domain/update"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
const (
maxBinaryBytes int64 = 200 << 20
maxChecksumsBytes int64 = 64 << 10
maxSignatureBytes int64 = 1 << 10
assetDownloadTimeout = 10 * time.Minute
maxAssetRedirects = 5
assetFilePermission = 0o644
signatureSuffix = ".sig"
)
var (
errAssetURLRefused = errors.New("the asset url is not https on the release feed host")
errAssetStatus = errors.New("the asset server answered with an error status")
errAssetTooLarge = errors.New("the asset is larger than its size cap")
errTooManyRedirects = errors.New("the asset url redirected too many times")
)
type FileOpener func(path string) (io.WriteCloser, error)
type HTTPAssetFetcher struct {
allowedHost string
allowInsecure bool
sizeCap int64
timeout time.Duration
transport http.RoundTripper
openFile FileOpener
}
var _ domain.AssetFetcher = &HTTPAssetFetcher{}
type FetcherOption func(*HTTPAssetFetcher)
func WithInsecureScheme() FetcherOption {
return func(f *HTTPAssetFetcher) { f.allowInsecure = true }
}
func WithSizeCap(bytes int64) FetcherOption {
return func(f *HTTPAssetFetcher) { f.sizeCap = bytes }
}
func WithTimeout(timeout time.Duration) FetcherOption {
return func(f *HTTPAssetFetcher) { f.timeout = timeout }
}
func WithTransport(transport http.RoundTripper) FetcherOption {
return func(f *HTTPAssetFetcher) { f.transport = transport }
}
func WithFileOpener(opener FileOpener) FetcherOption {
return func(f *HTTPAssetFetcher) { f.openFile = opener }
}
func NewHTTPAssetFetcher(feedURL string, options ...FetcherOption) *HTTPAssetFetcher {
fetcher := &HTTPAssetFetcher{
allowedHost: domain.HostOf(feedURL),
timeout: assetDownloadTimeout,
openFile: createAssetFile,
}
for _, option := range options {
option(fetcher)
}
return fetcher
}
func (f *HTTPAssetFetcher) Fetch(asset domain.Asset, destination string, report domain.ProgressFunc) error {
if !f.isAllowed(asset.URL) {
return errAssetURLRefused
}
ctx, cancel := context.WithTimeout(context.Background(), f.timeout)
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.URL, nil)
if err != nil {
return err
}
response, err := f.client().Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("%w: %d", errAssetStatus, response.StatusCode)
}
limit := f.capFor(asset.Name)
if response.ContentLength > limit {
return errAssetTooLarge
}
if err := f.writeBody(response.Body, destination, limit, asset.Size, report); err != nil {
_ = os.Remove(destination)
return err
}
return nil
}
func (f *HTTPAssetFetcher) writeBody(body io.Reader, destination string, limit int64, declaredSize int64, report domain.ProgressFunc) error {
file, err := f.openFile(destination)
if err != nil {
return err
}
counter := &progressWriter{destination: file, total: declaredSize, report: report}
written, copyErr := io.Copy(counter, io.LimitReader(body, limit+1))
closeErr := file.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
if written > limit {
return errAssetTooLarge
}
return nil
}
func (f *HTTPAssetFetcher) isAllowed(rawURL string) bool {
parsed, err := url.Parse(rawURL)
if err != nil || f.allowedHost == "" || parsed.Host != f.allowedHost {
return false
}
return parsed.Scheme == "https" || (f.allowInsecure && parsed.Scheme == "http")
}
func (f *HTTPAssetFetcher) client() *http.Client {
return &http.Client{
Transport: f.transport,
CheckRedirect: func(next *http.Request, via []*http.Request) error {
if len(via) >= maxAssetRedirects {
return errTooManyRedirects
}
if !f.isAllowed(next.URL.String()) {
return errAssetURLRefused
}
return nil
},
}
}
func (f *HTTPAssetFetcher) capFor(name string) int64 {
if f.sizeCap > 0 {
return f.sizeCap
}
if name == domain.ChecksumsAssetName {
return maxChecksumsBytes
}
if strings.HasSuffix(name, signatureSuffix) {
return maxSignatureBytes
}
return maxBinaryBytes
}
type progressWriter struct {
destination io.Writer
total int64
done int64
report domain.ProgressFunc
}
func (w *progressWriter) Write(chunk []byte) (int, error) {
written, err := w.destination.Write(chunk)
w.done += int64(written)
w.report(w.done, w.total)
return written, err
}
func createAssetFile(path string) (io.WriteCloser, error) {
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
return nil, err
}
return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, assetFilePermission)
}
@@ -0,0 +1,90 @@
package update
import (
domain "RenewCMS/internal/domain/update"
"encoding/json"
"os"
"path/filepath"
"sync"
)
type JSONJobStore struct {
path string
mu sync.Mutex
}
var _ domain.JobStore = &JSONJobStore{}
func NewJSONJobStore(path string) *JSONJobStore {
store := &JSONJobStore{path: path}
store.failInterruptedJob()
return store
}
func (s *JSONJobStore) failInterruptedJob() {
job, found := s.read()
if !found || !job.IsRunning() {
return
}
job.State = domain.StateFailed
job.Error = domain.ErrorKindDownload
job.DownloadedBytes = 0
job.TotalBytes = 0
_ = s.write(job)
}
func (s *JSONJobStore) Load() (domain.Job, bool) {
s.mu.Lock()
defer s.mu.Unlock()
job, found := s.read()
if !found {
return domain.Job{}, false
}
if job.State == domain.StateReady && !fileExists(job.StagedBinary) {
return domain.Job{}, false
}
return job, true
}
func (s *JSONJobStore) Save(job domain.Job) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.write(job)
}
func (s *JSONJobStore) read() (domain.Job, bool) {
content, err := os.ReadFile(s.path)
if err != nil {
return domain.Job{}, false
}
var job domain.Job
if err := json.Unmarshal(content, &job); err != nil || job.State == "" {
return domain.Job{}, false
}
return job, true
}
func (s *JSONJobStore) write(job domain.Job) error {
content, err := json.Marshal(job)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(s.path), os.ModePerm); err != nil {
return err
}
temporary := s.path + ".tmp"
if err := os.WriteFile(temporary, content, statusFilePermissions); err != nil {
return err
}
return os.Rename(temporary, s.path)
}
func fileExists(path string) bool {
if path == "" {
return false
}
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
@@ -0,0 +1,58 @@
package update
import (
"RenewCMS/internal/domain/update"
"os"
"path/filepath"
)
const (
stagedDirectoryPermissions = 0o755
stagedBinaryPermissions = 0o755
)
type OSStagingArea struct {
root string
}
var _ update.StagingArea = &OSStagingArea{}
func NewOSStagingArea(root string) *OSStagingArea {
return &OSStagingArea{root: root}
}
func (a *OSStagingArea) Prepare(tag string) error {
directory := filepath.Join(a.root, tag)
if err := os.RemoveAll(directory); err != nil {
return err
}
return os.MkdirAll(directory, stagedDirectoryPermissions)
}
func (a *OSStagingArea) PathFor(tag string, name string) string {
return filepath.Join(a.root, tag, name)
}
func (a *OSStagingArea) Size(path string) (int64, error) {
info, err := os.Stat(path)
if err != nil {
return 0, err
}
return info.Size(), nil
}
func (a *OSStagingArea) ReadChecksums(path string) (string, error) {
content, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(content), nil
}
func (a *OSStagingArea) MakeExecutable(path string) error {
return os.Chmod(path, stagedBinaryPermissions)
}
func (a *OSStagingArea) Remove(tag string) error {
return os.RemoveAll(filepath.Join(a.root, tag))
}
@@ -0,0 +1,87 @@
package update
import (
domain "RenewCMS/internal/domain/update"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gorm.io/gorm"
)
var errBackupExists = errors.New("a backup with this name already exists")
const (
backupsToKeep = 3
backupFileExtension = ".db"
backupFilePrefix = "renewcms-"
backupTimeLayout = "20060102T150405.000000000Z"
)
type SQLiteDatabaseBackup struct {
db *gorm.DB
directory string
now func() time.Time
}
var _ domain.DatabaseBackup = &SQLiteDatabaseBackup{}
func NewSQLiteDatabaseBackup(db *gorm.DB, directory string, now func() time.Time) *SQLiteDatabaseBackup {
return &SQLiteDatabaseBackup{db: db, directory: directory, now: now}
}
func (b *SQLiteDatabaseBackup) Backup() (string, error) {
if err := os.MkdirAll(b.directory, os.ModePerm); err != nil {
return "", err
}
target := filepath.Join(b.directory, backupFilePrefix+b.now().UTC().Format(backupTimeLayout)+backupFileExtension)
if _, err := os.Stat(target); err == nil {
return "", errBackupExists
}
if err := b.db.Exec("VACUUM INTO ?", target).Error; err != nil {
_ = os.Remove(target)
return "", err
}
if err := b.prune(); err != nil {
return "", err
}
return target, nil
}
type backupFile struct {
path string
modified time.Time
}
func (b *SQLiteDatabaseBackup) prune() error {
entries, err := os.ReadDir(b.directory)
if err != nil {
return err
}
files := make([]backupFile, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), backupFileExtension) {
continue
}
info, err := entry.Info()
if err != nil {
return err
}
files = append(files, backupFile{path: filepath.Join(b.directory, entry.Name()), modified: info.ModTime()})
}
sort.Slice(files, func(i, j int) bool {
if files[i].modified.Equal(files[j].modified) {
return files[i].path > files[j].path
}
return files[i].modified.After(files[j].modified)
})
for _, stale := range files[min(backupsToKeep, len(files)):] {
if err := os.Remove(stale.path); err != nil {
return err
}
}
return nil
}
@@ -4,17 +4,29 @@ import "RenewCMS/internal/domain/update"
type GetUpdateStatusUseCase struct {
store update.StatusStore
jobs update.JobStore
helper update.HelperDetector
currentVersion string
}
func NewGetUpdateStatusUseCase(store update.StatusStore, currentVersion string) *GetUpdateStatusUseCase {
return &GetUpdateStatusUseCase{store: store, currentVersion: currentVersion}
func NewGetUpdateStatusUseCase(store update.StatusStore, jobs update.JobStore, helper update.HelperDetector, currentVersion string) *GetUpdateStatusUseCase {
return &GetUpdateStatusUseCase{store: store, jobs: jobs, helper: helper, currentVersion: currentVersion}
}
func (g *GetUpdateStatusUseCase) GetStatus() update.UpdateStatus {
result, found := g.store.Load()
if !found {
return update.NewStatus(g.currentVersion, nil)
func (g *GetUpdateStatusUseCase) GetStatus(isOwner bool) update.UpdateStatus {
return buildUpdateStatus(g.store, g.jobs, g.currentVersion, isOwner && g.helper.Installed())
}
func buildUpdateStatus(store update.StatusStore, jobs update.JobStore, currentVersion string, canApply bool) update.UpdateStatus {
var status update.UpdateStatus
if result, found := store.Load(); found {
status = update.NewStatus(currentVersion, &result)
} else {
status = update.NewStatus(currentVersion, nil)
}
return update.NewStatus(g.currentVersion, &result)
if job, found := jobs.Load(); found {
status = status.WithJob(job)
}
status.CanApply = canApply
return status
}
@@ -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)
}
}