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) } }