feat: update button (download, verify, stage)
This commit is contained in:
1 parent
834493a4f5
commit
70460e4170
41 files changed
+5345
-97
No files matched your search
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user