383 lines
11 KiB
Go
383 lines
11 KiB
Go
package useCases
|
|
|
|
import (
|
|
"RenewCMS/internal/domain/update"
|
|
"errors"
|
|
"fmt"
|
|
"path"
|
|
"time"
|
|
)
|
|
|
|
type ApplyRestartRequestUseCase struct {
|
|
requests update.RestartRequestStore
|
|
results update.ResultStore
|
|
running update.RunningInfoStore
|
|
files update.HelperFilesystem
|
|
service update.ServiceControl
|
|
health update.HealthChecker
|
|
verifier update.SignatureVerifier
|
|
clock update.Clock
|
|
logger update.Logger
|
|
layout update.HelperLayout
|
|
platform update.Platform
|
|
}
|
|
|
|
type ApplyRestartRequestDependencies struct {
|
|
Requests update.RestartRequestStore
|
|
Results update.ResultStore
|
|
Running update.RunningInfoStore
|
|
Files update.HelperFilesystem
|
|
Service update.ServiceControl
|
|
Health update.HealthChecker
|
|
Verifier update.SignatureVerifier
|
|
Clock update.Clock
|
|
Logger update.Logger
|
|
Layout update.HelperLayout
|
|
Platform update.Platform
|
|
}
|
|
|
|
func NewApplyRestartRequestUseCase(dependencies ApplyRestartRequestDependencies) *ApplyRestartRequestUseCase {
|
|
return &ApplyRestartRequestUseCase{
|
|
requests: dependencies.Requests,
|
|
results: dependencies.Results,
|
|
running: dependencies.Running,
|
|
files: dependencies.Files,
|
|
service: dependencies.Service,
|
|
health: dependencies.Health,
|
|
verifier: dependencies.Verifier,
|
|
clock: dependencies.Clock,
|
|
logger: dependencies.Logger,
|
|
layout: dependencies.Layout,
|
|
platform: dependencies.Platform,
|
|
}
|
|
}
|
|
|
|
type helperRun struct {
|
|
apply *ApplyRestartRequestUseCase
|
|
result update.Result
|
|
tempDir string
|
|
stagedTag string
|
|
}
|
|
|
|
type verifiedCopy struct {
|
|
binaryPath string
|
|
checksum string
|
|
}
|
|
|
|
func (a *ApplyRestartRequestUseCase) Apply() (err error) {
|
|
request, readErr := a.requests.Read()
|
|
if errors.Is(readErr, update.ErrNoRestartRequest) {
|
|
a.logger.Printf("no restart request found, nothing to do")
|
|
return nil
|
|
}
|
|
run := &helperRun{apply: a, result: update.Result{
|
|
Outcome: update.OutcomeRunning,
|
|
Kind: request.Kind,
|
|
Tag: request.Tag,
|
|
StartedAt: a.clock.Now().UTC(),
|
|
}}
|
|
defer run.recoverPanic(&err)
|
|
|
|
deleteErr := a.requests.Delete()
|
|
symlinkErr := a.symlinkedDataFile()
|
|
if info, found := a.running.Read(); found {
|
|
run.result.FromVersion = info.Version
|
|
}
|
|
run.record()
|
|
|
|
for _, refusal := range []error{readErr, deleteErr, symlinkErr} {
|
|
if refusal != nil {
|
|
return run.fail(refusal)
|
|
}
|
|
}
|
|
return run.dispatch()
|
|
}
|
|
|
|
func (a *ApplyRestartRequestUseCase) symlinkedDataFile() error {
|
|
for _, candidate := range []string{a.layout.ResultPath(), a.layout.ResultTempPath()} {
|
|
if info, err := a.files.Lstat(candidate); err == nil && info.IsSymlink {
|
|
return update.SymlinkError{Path: candidate}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *helperRun) dispatch() error {
|
|
switch r.result.Kind {
|
|
case update.RestartKindRestart:
|
|
return r.plainRestart()
|
|
case update.RestartKindUpdate:
|
|
return r.installUpdate()
|
|
}
|
|
return r.fail(fmt.Errorf("unknown restart kind %q", r.result.Kind))
|
|
}
|
|
|
|
func (r *helperRun) plainRestart() error {
|
|
if !r.apply.restartAndAwait("") {
|
|
return r.failLoudly(errors.New("the site did not come back after the restart"))
|
|
}
|
|
return r.finish(update.OutcomeSucceeded)
|
|
}
|
|
|
|
func (r *helperRun) installUpdate() error {
|
|
a := r.apply
|
|
tag := r.result.Tag
|
|
if r.result.FromVersion == "" {
|
|
return r.fail(errors.New("the running version could not be read from update-running.json"))
|
|
}
|
|
if err := update.ValidateRestartTag(tag, r.result.FromVersion); err != nil {
|
|
return r.fail(fmt.Errorf("tag %q: %w", tag, err))
|
|
}
|
|
r.stagedTag = tag
|
|
|
|
assetName := update.AssetName(tag, a.platform)
|
|
if err := a.requireStagedFiles(tag, assetName); err != nil {
|
|
return r.fail(err)
|
|
}
|
|
verified, err := r.copyAndVerify(tag, assetName)
|
|
if err != nil {
|
|
return r.fail(err)
|
|
}
|
|
if err := a.swapBinary(verified); err != nil {
|
|
return r.fail(err)
|
|
}
|
|
if a.restartAndAwait(tag) {
|
|
return r.finish(update.OutcomeSucceeded)
|
|
}
|
|
return r.rollBack()
|
|
}
|
|
|
|
func (r *helperRun) copyAndVerify(tag string, assetName string) (verifiedCopy, error) {
|
|
a := r.apply
|
|
tempDir, err := a.files.MakePrivateTempDir()
|
|
if err != nil {
|
|
return verifiedCopy{}, err
|
|
}
|
|
r.tempDir = tempDir
|
|
stagedDir := a.layout.StagedDir(tag)
|
|
for _, name := range []string{assetName, update.ChecksumsAssetName, update.SignatureAssetName} {
|
|
original := path.Join(stagedDir, name)
|
|
if err := a.files.Copy(original, path.Join(tempDir, name), update.PrivateFileMode); err != nil {
|
|
return verifiedCopy{}, fmt.Errorf("copying %s: %w", original, err)
|
|
}
|
|
}
|
|
return a.verifyCopy(tempDir, assetName)
|
|
}
|
|
|
|
func (a *ApplyRestartRequestUseCase) verifyCopy(tempDir string, assetName string) (verifiedCopy, error) {
|
|
checksumsPath := path.Join(tempDir, update.ChecksumsAssetName)
|
|
if err := a.verifier.Verify(checksumsPath, path.Join(tempDir, update.SignatureAssetName)); err != nil {
|
|
return verifiedCopy{}, fmt.Errorf("signature check: %w", err)
|
|
}
|
|
checksums, err := a.files.ReadFile(checksumsPath)
|
|
if err != nil {
|
|
return verifiedCopy{}, err
|
|
}
|
|
expected, found := update.ChecksumFor(string(checksums), assetName)
|
|
if !found {
|
|
return verifiedCopy{}, fmt.Errorf("checksums.txt has no line for %q", assetName)
|
|
}
|
|
binaryPath := path.Join(tempDir, assetName)
|
|
content, err := a.files.ReadFile(binaryPath)
|
|
if err != nil {
|
|
return verifiedCopy{}, err
|
|
}
|
|
if update.SHA256Hex(content) != expected {
|
|
return verifiedCopy{}, fmt.Errorf("sha-256 mismatch for the staged %q", assetName)
|
|
}
|
|
return verifiedCopy{binaryPath: binaryPath, checksum: expected}, nil
|
|
}
|
|
|
|
func (a *ApplyRestartRequestUseCase) requireStagedFiles(tag string, assetName string) error {
|
|
for _, directory := range []string{a.layout.UpdatesDir(), a.layout.StagedDir(tag)} {
|
|
if err := a.requireDirectory(directory); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, name := range []string{assetName, update.ChecksumsAssetName, update.SignatureAssetName} {
|
|
if err := a.requireRegularFile(path.Join(a.layout.StagedDir(tag), name)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *ApplyRestartRequestUseCase) requireDirectory(target string) error {
|
|
return a.requireEntry(target, "a directory", func(info update.FileInfo) bool { return info.IsDir })
|
|
}
|
|
|
|
func (a *ApplyRestartRequestUseCase) requireRegularFile(target string) error {
|
|
return a.requireEntry(target, "a regular file", func(info update.FileInfo) bool { return info.IsRegular })
|
|
}
|
|
|
|
func (a *ApplyRestartRequestUseCase) requireEntry(target string, description string, matches func(update.FileInfo) bool) error {
|
|
info, err := a.files.Lstat(target)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", target, err)
|
|
}
|
|
if info.IsSymlink {
|
|
return update.SymlinkError{Path: target}
|
|
}
|
|
if !matches(info) {
|
|
return fmt.Errorf("%s is not %s", target, description)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *ApplyRestartRequestUseCase) swapBinary(verified verifiedCopy) error {
|
|
binary := a.layout.BinaryPath
|
|
if err := a.replaceViaTemp(binary, a.layout.PreviousPath(), binary+update.PreviousTempSuffix); err != nil {
|
|
return fmt.Errorf("keeping the previous binary: %w", err)
|
|
}
|
|
installTemp := binary + update.InstallTempSuffix
|
|
a.removeQuietly(installTemp)
|
|
if err := a.files.Copy(verified.binaryPath, installTemp, update.ExecutableMode); err != nil {
|
|
a.removeQuietly(installTemp)
|
|
return fmt.Errorf("writing the new binary: %w", err)
|
|
}
|
|
if err := a.installIfUnchanged(installTemp, verified.checksum); err != nil {
|
|
a.removeQuietly(installTemp)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *ApplyRestartRequestUseCase) installIfUnchanged(installTemp string, checksum string) error {
|
|
content, err := a.files.ReadFile(installTemp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if update.SHA256Hex(content) != checksum {
|
|
return fmt.Errorf("%s changed after it was verified", installTemp)
|
|
}
|
|
if err := a.files.Chmod(installTemp, update.ExecutableMode); err != nil {
|
|
return err
|
|
}
|
|
if err := a.files.Rename(installTemp, a.layout.BinaryPath); err != nil {
|
|
return fmt.Errorf("installing the new binary: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *ApplyRestartRequestUseCase) replaceViaTemp(source string, destination string, temporary string) error {
|
|
a.removeQuietly(temporary)
|
|
if err := a.files.Copy(source, temporary, update.ExecutableMode); err != nil {
|
|
a.removeQuietly(temporary)
|
|
return err
|
|
}
|
|
if err := a.files.Rename(temporary, destination); err != nil {
|
|
a.removeQuietly(temporary)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *helperRun) rollBack() error {
|
|
a := r.apply
|
|
a.logger.Printf("version %s did not come up, restoring %s", r.result.Tag, a.layout.PreviousPath())
|
|
restoreTemp := a.layout.BinaryPath + update.RestoreTempSuffix
|
|
if err := a.replaceViaTemp(a.layout.PreviousPath(), a.layout.BinaryPath, restoreTemp); err != nil {
|
|
return r.failLoudly(fmt.Errorf("the previous binary could not be restored, the site may be down: %w", err))
|
|
}
|
|
if !a.restartAndAwait(r.result.FromVersion) {
|
|
return r.failLoudly(errors.New("the previous version did not come back after the rollback"))
|
|
}
|
|
return r.finish(update.OutcomeRolledBack)
|
|
}
|
|
|
|
func (a *ApplyRestartRequestUseCase) restartAndAwait(expectedVersion string) bool {
|
|
baseline, _ := a.running.Read()
|
|
restartedAt := a.clock.Now()
|
|
if err := a.service.Restart(); err != nil {
|
|
a.logger.Printf("restarting the service failed: %v", err)
|
|
return false
|
|
}
|
|
for a.clock.Now().Sub(restartedAt) < update.RestartWindow {
|
|
if a.isBackUp(baseline, restartedAt, expectedVersion) {
|
|
return true
|
|
}
|
|
a.clock.Sleep(update.PollInterval)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (a *ApplyRestartRequestUseCase) isBackUp(baseline update.RunningInfo, restartedAt time.Time, expectedVersion string) bool {
|
|
if !a.health.Healthy() {
|
|
return false
|
|
}
|
|
info, found := a.running.Read()
|
|
if !found || !info.IsNewerThan(baseline, restartedAt) {
|
|
return false
|
|
}
|
|
return expectedVersion == "" || info.Version == expectedVersion
|
|
}
|
|
|
|
func (r *helperRun) record() {
|
|
if err := r.apply.results.Write(r.result); err != nil {
|
|
r.apply.logger.Printf("update-result.json could not be written: %v", err)
|
|
}
|
|
}
|
|
|
|
func (r *helperRun) finish(outcome string) error {
|
|
finishedAt := r.apply.clock.Now().UTC()
|
|
r.result.Outcome = outcome
|
|
r.result.FinishedAt = &finishedAt
|
|
r.record()
|
|
r.cleanUp()
|
|
return nil
|
|
}
|
|
|
|
func (r *helperRun) fail(reason error) error {
|
|
r.apply.logger.Printf("restart request refused or failed: %v", reason)
|
|
return r.finish(update.OutcomeFailed)
|
|
}
|
|
|
|
func (r *helperRun) failLoudly(reason error) error {
|
|
r.apply.logger.Printf("restart request failed: %v", reason)
|
|
_ = r.finish(update.OutcomeFailed)
|
|
return reason
|
|
}
|
|
|
|
func (r *helperRun) recoverPanic(err *error) {
|
|
if recovered := recover(); recovered != nil {
|
|
*err = r.failLoudly(fmt.Errorf("panic: %v", recovered))
|
|
}
|
|
}
|
|
|
|
func (r *helperRun) cleanUp() {
|
|
a := r.apply
|
|
if r.tempDir != "" {
|
|
a.removeTreeQuietly(r.tempDir)
|
|
}
|
|
if r.stagedTag != "" {
|
|
a.removeStaged(r.stagedTag)
|
|
}
|
|
}
|
|
|
|
func (a *ApplyRestartRequestUseCase) removeStaged(tag string) {
|
|
if err := a.requireDirectory(a.layout.UpdatesDir()); err != nil {
|
|
a.logger.Printf("staged files left in place: %v", err)
|
|
return
|
|
}
|
|
staged := a.layout.StagedDir(tag)
|
|
info, err := a.files.Lstat(staged)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if info.IsSymlink {
|
|
a.removeQuietly(staged)
|
|
return
|
|
}
|
|
a.removeTreeQuietly(staged)
|
|
}
|
|
|
|
func (a *ApplyRestartRequestUseCase) removeQuietly(target string) {
|
|
_ = a.files.Remove(target)
|
|
}
|
|
|
|
func (a *ApplyRestartRequestUseCase) removeTreeQuietly(target string) {
|
|
if err := a.files.RemoveAll(target); err != nil {
|
|
a.logger.Printf("could not remove %s: %v", target, err)
|
|
}
|
|
}
|