feat: restart to apply update, privileged helper, rollback
This commit is contained in:
1 parent
70460e4170
commit
54e8fd5fed
63 files changed
+8635
-164
No files matched your search
@@ -59,6 +59,7 @@ type UpdateStatus struct {
|
||||
Progress *Progress `json:"progress"`
|
||||
Error *string `json:"error"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at"`
|
||||
}
|
||||
|
||||
func NewResult(release Release, checkedAt time.Time) CheckResult {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNeedsLinux = errors.New("Browser updates need Linux with systemd.")
|
||||
ErrNeedsSystemd = errors.New("Browser updates need systemd, which this machine is not running.")
|
||||
ErrNeedsRoot = errors.New("Run this command as root, for example with sudo.")
|
||||
ErrServiceNotFound = errors.New("Could not find the RenewCMS service. Pass --service, --binary and --data-dir.")
|
||||
ErrDataDirNotFound = errors.New("Could not find the data directory. Pass --data-dir.")
|
||||
ErrInvalidPort = errors.New("The port must be a number between 1 and 65535.")
|
||||
)
|
||||
|
||||
const (
|
||||
OSLinux = "linux"
|
||||
pathUnitDescription = "Watch for a RenewCMS restart request"
|
||||
)
|
||||
|
||||
type ServiceDescription struct {
|
||||
Name string
|
||||
BinaryPath string
|
||||
DataDir string
|
||||
EnvironmentFile string
|
||||
}
|
||||
|
||||
type InstallOptions struct {
|
||||
Service string
|
||||
Binary string
|
||||
DataDir string
|
||||
Port string
|
||||
}
|
||||
|
||||
type MachineProbe interface {
|
||||
OS() string
|
||||
SystemdRunning() bool
|
||||
IsRoot() bool
|
||||
}
|
||||
|
||||
type ServiceLocator interface {
|
||||
Locate(name string) (ServiceDescription, bool)
|
||||
PortFromEnvironmentFile(path string) (string, bool)
|
||||
}
|
||||
|
||||
type UnitWriter interface {
|
||||
Write(name string, content string) error
|
||||
Remove(name string) (bool, error)
|
||||
}
|
||||
|
||||
type UnitManager interface {
|
||||
DaemonReload() error
|
||||
EnableNow(unit string) error
|
||||
DisableNow(unit string) error
|
||||
}
|
||||
|
||||
type HelperStatusWriter interface {
|
||||
Write(dataDir string, status HelperStatus) error
|
||||
Remove(dataDir string) (bool, error)
|
||||
}
|
||||
|
||||
func CheckInstallEnvironment(probe MachineProbe) error {
|
||||
if probe.OS() != OSLinux {
|
||||
return ErrNeedsLinux
|
||||
}
|
||||
if !probe.SystemdRunning() {
|
||||
return ErrNeedsSystemd
|
||||
}
|
||||
if !probe.IsRoot() {
|
||||
return ErrNeedsRoot
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidatePort(port string) error {
|
||||
number, err := strconv.Atoi(port)
|
||||
if err != nil || number < 1 || number > 65535 {
|
||||
return ErrInvalidPort
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func RenderPathUnit(layout HelperLayout) string {
|
||||
lines := []string{
|
||||
"[Unit]",
|
||||
"Description=" + pathUnitDescription,
|
||||
"",
|
||||
"[Path]",
|
||||
"PathExists=" + layout.RestartRequestPath(),
|
||||
"Unit=" + ServiceUnitName,
|
||||
"",
|
||||
"[Install]",
|
||||
"WantedBy=multi-user.target",
|
||||
"",
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func RenderServiceUnit(options InstallOptions) string {
|
||||
command := []string{
|
||||
quoteUnitArgument(options.Binary),
|
||||
"apply-update",
|
||||
"--service", quoteUnitArgument(options.Service),
|
||||
"--binary", quoteUnitArgument(options.Binary),
|
||||
"--data-dir", quoteUnitArgument(options.DataDir),
|
||||
"--port", options.Port,
|
||||
}
|
||||
lines := []string{
|
||||
"[Unit]",
|
||||
"Description=RenewCMS update helper",
|
||||
"",
|
||||
"[Service]",
|
||||
"Type=oneshot",
|
||||
"User=root",
|
||||
"ExecStart=" + strings.Join(command, " "),
|
||||
"",
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
var unitArgumentEscaper = strings.NewReplacer(
|
||||
`\`, `\\`,
|
||||
`"`, `\"`,
|
||||
`%`, `%%`,
|
||||
`$`, `$$`,
|
||||
"\n", `\n`,
|
||||
"\r", `\r`,
|
||||
"\t", `\t`,
|
||||
)
|
||||
|
||||
func quoteUnitArgument(value string) string {
|
||||
if !strings.ContainsAny(value, " \t\r\n\"'\\%$;") {
|
||||
return value
|
||||
}
|
||||
return `"` + unitArgumentEscaper.Replace(value) + `"`
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"path"
|
||||
)
|
||||
|
||||
const (
|
||||
RestartRequestFileName = "update-restart-request.json"
|
||||
ResultFileName = "update-result.json"
|
||||
HelperStatusFileName = "update-helper.json"
|
||||
RunningInfoFileName = "update-running.json"
|
||||
UpdatesDirName = "updates"
|
||||
HelperStatusVersion = 1
|
||||
|
||||
PathUnitName = "renewcms-update.path"
|
||||
ServiceUnitName = "renewcms-update.service"
|
||||
DefaultService = "renewcms"
|
||||
DefaultPort = "8080"
|
||||
|
||||
PreviousSuffix = ".previous"
|
||||
PreviousTempSuffix = ".previous.tmp"
|
||||
InstallTempSuffix = ".new"
|
||||
RestoreTempSuffix = ".restore"
|
||||
ResultTempSuffix = ".tmp"
|
||||
ExecutableMode = 0o755
|
||||
PrivateFileMode = 0o600
|
||||
)
|
||||
|
||||
type HelperLayout struct {
|
||||
DataDir string
|
||||
BinaryPath string
|
||||
}
|
||||
|
||||
func (l HelperLayout) UpdatesDir() string {
|
||||
return path.Join(l.DataDir, UpdatesDirName)
|
||||
}
|
||||
|
||||
func (l HelperLayout) StagedDir(tag string) string {
|
||||
return path.Join(l.UpdatesDir(), tag)
|
||||
}
|
||||
|
||||
func (l HelperLayout) ResultPath() string {
|
||||
return path.Join(l.DataDir, ResultFileName)
|
||||
}
|
||||
|
||||
func (l HelperLayout) ResultTempPath() string {
|
||||
return l.ResultPath() + ResultTempSuffix
|
||||
}
|
||||
|
||||
func (l HelperLayout) RestartRequestPath() string {
|
||||
return path.Join(l.DataDir, RestartRequestFileName)
|
||||
}
|
||||
|
||||
func (l HelperLayout) PreviousPath() string {
|
||||
return l.BinaryPath + PreviousSuffix
|
||||
}
|
||||
|
||||
func SHA256Hex(content []byte) string {
|
||||
sum := sha256.Sum256(content)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
StateRestarting = "restarting"
|
||||
StateSucceeded = "succeeded"
|
||||
|
||||
ErrorKindRolledBack = "rolled_back"
|
||||
ErrorKindInstall = "install"
|
||||
|
||||
RestartKindUpdate = "update"
|
||||
RestartKindRestart = "restart"
|
||||
|
||||
OutcomeRunning = "running"
|
||||
OutcomeSucceeded = "succeeded"
|
||||
OutcomeRolledBack = "rolled_back"
|
||||
OutcomeFailed = "failed"
|
||||
|
||||
RunningResultMaxAge = 5 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRestartPending = errors.New("a restart is already pending or running")
|
||||
ErrRestartKindMismatch = errors.New("the requested restart does not match the current state")
|
||||
ErrNoRestartRequest = errors.New("there is no restart request")
|
||||
ErrSymlinkRefused = errors.New("a symbolic link was found where a regular file is required")
|
||||
ErrInvalidRestartTag = errors.New("the restart request names an invalid version")
|
||||
ErrHelperUnsupported = errors.New("the privileged helper needs Linux")
|
||||
)
|
||||
|
||||
var restartTagPattern = regexp.MustCompile(`^v[0-9]+(\.[0-9]+)*(-[0-9A-Za-z.]+)?$`)
|
||||
|
||||
type RestartRequest struct {
|
||||
Kind string `json:"kind"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
RequestedAt time.Time `json:"requested_at"`
|
||||
RequestedBy uint32 `json:"requested_by"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Outcome string `json:"outcome"`
|
||||
Kind string `json:"kind"`
|
||||
Tag string `json:"tag"`
|
||||
FromVersion string `json:"from_version"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at"`
|
||||
}
|
||||
|
||||
type RunningInfo struct {
|
||||
Version string `json:"version"`
|
||||
Pid int `json:"pid"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
}
|
||||
|
||||
type HelperStatus struct {
|
||||
Version int `json:"version"`
|
||||
Service string `json:"service"`
|
||||
Binary string `json:"binary"`
|
||||
DataDir string `json:"data_dir"`
|
||||
InstalledAt time.Time `json:"installed_at"`
|
||||
}
|
||||
|
||||
type FileInfo struct {
|
||||
IsSymlink bool
|
||||
IsDir bool
|
||||
IsRegular bool
|
||||
}
|
||||
|
||||
type RestartRequestStore interface {
|
||||
Write(request RestartRequest) error
|
||||
Read() (RestartRequest, error)
|
||||
Delete() error
|
||||
}
|
||||
|
||||
type ResultStore interface {
|
||||
Read() (Result, bool)
|
||||
Write(result Result) error
|
||||
Delete() error
|
||||
}
|
||||
|
||||
type RunningInfoStore interface {
|
||||
Write(info RunningInfo) error
|
||||
Read() (RunningInfo, bool)
|
||||
}
|
||||
|
||||
type HelperFilesystem interface {
|
||||
Lstat(path string) (FileInfo, error)
|
||||
ReadFile(path string) ([]byte, error)
|
||||
CreateExclusive(path string, content []byte, mode uint32) error
|
||||
Copy(source string, destination string, mode uint32) error
|
||||
Rename(oldPath string, newPath string) error
|
||||
Remove(path string) error
|
||||
RemoveAll(path string) error
|
||||
Chmod(path string, mode uint32) error
|
||||
MakePrivateTempDir() (string, error)
|
||||
}
|
||||
|
||||
type ServiceControl interface {
|
||||
Restart() error
|
||||
Status() (bool, error)
|
||||
}
|
||||
|
||||
type HealthChecker interface {
|
||||
Healthy() bool
|
||||
}
|
||||
|
||||
type Clock interface {
|
||||
Now() time.Time
|
||||
Sleep(duration time.Duration)
|
||||
}
|
||||
|
||||
func ValidateRestartTag(tag string, runningVersion string) error {
|
||||
if !restartTagPattern.MatchString(tag) {
|
||||
return ErrInvalidRestartTag
|
||||
}
|
||||
if _, err := ParseVersion(tag); err != nil || !IsNewer(runningVersion, tag) {
|
||||
return ErrInvalidRestartTag
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r Result) IsRunning() bool {
|
||||
return r.Outcome == OutcomeRunning
|
||||
}
|
||||
|
||||
func (r Result) IsKnown() bool {
|
||||
switch r.Outcome {
|
||||
case OutcomeRunning, OutcomeSucceeded, OutcomeRolledBack, OutcomeFailed:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (i RunningInfo) IsNewerThan(previous RunningInfo, restartedAt time.Time) bool {
|
||||
return i.Pid != previous.Pid || i.StartedAt.After(restartedAt)
|
||||
}
|
||||
|
||||
const (
|
||||
RestartWindow = 60 * time.Second
|
||||
PollInterval = time.Second
|
||||
)
|
||||
|
||||
type SymlinkError struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
func (e SymlinkError) Error() string {
|
||||
return e.Path + " is a symbolic link"
|
||||
}
|
||||
|
||||
func (e SymlinkError) Is(target error) bool {
|
||||
return target == ErrSymlinkRefused
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package update
|
||||
|
||||
import "time"
|
||||
|
||||
func (s UpdateStatus) WithRestartRequest(request RestartRequest) UpdateStatus {
|
||||
return s.settled(StateRestarting, "", request.RequestedAt)
|
||||
}
|
||||
|
||||
func (s UpdateStatus) WithResult(result Result, currentVersion string, now time.Time) UpdateStatus {
|
||||
if result.IsRunning() && now.Sub(result.StartedAt) < RunningResultMaxAge {
|
||||
return s.settled(StateRestarting, "", result.StartedAt)
|
||||
}
|
||||
if result.IsRunning() || result.Outcome == OutcomeFailed {
|
||||
return s.settled(StateFailed, ErrorKindInstall, result.StartedAt)
|
||||
}
|
||||
if result.Outcome == OutcomeRolledBack {
|
||||
return s.settled(StateFailed, ErrorKindRolledBack, result.StartedAt)
|
||||
}
|
||||
if result.Kind == RestartKindUpdate && result.Tag == currentVersion {
|
||||
return s.settled(StateSucceeded, "", result.StartedAt)
|
||||
}
|
||||
s.State = StateIdle
|
||||
s.Progress = nil
|
||||
s.Error = nil
|
||||
s.StartedAt = nil
|
||||
return s
|
||||
}
|
||||
|
||||
func (s UpdateStatus) settled(state string, errorKind string, startedAt time.Time) UpdateStatus {
|
||||
s.State = state
|
||||
s.Progress = nil
|
||||
s.Error = nil
|
||||
if errorKind != "" {
|
||||
s.Error = &errorKind
|
||||
}
|
||||
s.StartedAt = &startedAt
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
const readableFilePermissions = 0o644
|
||||
|
||||
func writeFileAtomically(path string, content []byte, permissions os.FileMode) error {
|
||||
temporary := path + ".tmp"
|
||||
if err := os.WriteFile(temporary, content, permissions); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(temporary, path); err != nil {
|
||||
_ = os.Remove(temporary)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSONAtomically(path string, value any, permissions os.FileMode) error {
|
||||
content, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileAtomically(path, content, permissions)
|
||||
}
|
||||
|
||||
func removeFile(path string) (bool, error) {
|
||||
err := os.Remove(path)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func removeIfPresent(path string) error {
|
||||
_, err := removeFile(path)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
type FSRestartRequestStore struct {
|
||||
files domain.HelperFilesystem
|
||||
path string
|
||||
}
|
||||
|
||||
var _ domain.RestartRequestStore = &FSRestartRequestStore{}
|
||||
|
||||
func NewFSRestartRequestStore(files domain.HelperFilesystem, path string) *FSRestartRequestStore {
|
||||
return &FSRestartRequestStore{files: files, path: path}
|
||||
}
|
||||
|
||||
func (s *FSRestartRequestStore) Write(request domain.RestartRequest) error {
|
||||
return writeJSONThroughTemporary(s.files, s.path, request, statusFilePermissions)
|
||||
}
|
||||
|
||||
func (s *FSRestartRequestStore) Read() (domain.RestartRequest, error) {
|
||||
info, err := s.files.Lstat(s.path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return domain.RestartRequest{}, domain.ErrNoRestartRequest
|
||||
}
|
||||
if err != nil {
|
||||
return domain.RestartRequest{}, err
|
||||
}
|
||||
if info.IsSymlink {
|
||||
return domain.RestartRequest{}, domain.SymlinkError{Path: s.path}
|
||||
}
|
||||
if !info.IsRegular {
|
||||
return domain.RestartRequest{}, fmt.Errorf("%s is not a regular file", s.path)
|
||||
}
|
||||
content, err := s.files.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return domain.RestartRequest{}, err
|
||||
}
|
||||
var request domain.RestartRequest
|
||||
return request, json.Unmarshal(content, &request)
|
||||
}
|
||||
|
||||
func (s *FSRestartRequestStore) Delete() error {
|
||||
return removeThrough(s.files, s.path)
|
||||
}
|
||||
|
||||
func removeThrough(files domain.HelperFilesystem, path string) error {
|
||||
if err := files.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSONThroughTemporary(files domain.HelperFilesystem, path string, value any, mode uint32) error {
|
||||
content, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeThroughTemporary(files, path, content, mode)
|
||||
}
|
||||
|
||||
func writeThroughTemporary(files domain.HelperFilesystem, path string, content []byte, mode uint32) error {
|
||||
temporary := path + domain.ResultTempSuffix
|
||||
if _, err := files.Lstat(temporary); err == nil {
|
||||
if err := files.Remove(temporary); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := files.CreateExclusive(temporary, content, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := files.Rename(temporary, path); err != nil {
|
||||
_ = files.Remove(temporary)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
)
|
||||
|
||||
type FSResultStore struct {
|
||||
files domain.HelperFilesystem
|
||||
path string
|
||||
}
|
||||
|
||||
var _ domain.ResultStore = &FSResultStore{}
|
||||
|
||||
func NewFSResultStore(files domain.HelperFilesystem, path string) *FSResultStore {
|
||||
return &FSResultStore{files: files, path: path}
|
||||
}
|
||||
|
||||
func (s *FSResultStore) Read() (domain.Result, bool) {
|
||||
info, err := s.files.Lstat(s.path)
|
||||
if err != nil || !info.IsRegular {
|
||||
return domain.Result{}, false
|
||||
}
|
||||
content, err := s.files.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return domain.Result{}, false
|
||||
}
|
||||
return decodeResult(content)
|
||||
}
|
||||
|
||||
func (s *FSResultStore) Write(result domain.Result) error {
|
||||
return writeJSONThroughTemporary(s.files, s.path, result, readableFilePermissions)
|
||||
}
|
||||
|
||||
func (s *FSResultStore) Delete() error {
|
||||
return removeThrough(s.files, s.path)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const healthCheckTimeout = 2 * time.Second
|
||||
|
||||
type HTTPHealthChecker struct {
|
||||
url string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
var _ domain.HealthChecker = &HTTPHealthChecker{}
|
||||
|
||||
func NewHTTPHealthChecker(port string) *HTTPHealthChecker {
|
||||
return &HTTPHealthChecker{
|
||||
url: "http://127.0.0.1:" + port + "/healthz",
|
||||
client: &http.Client{Timeout: healthCheckTimeout},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *HTTPHealthChecker) Healthy() bool {
|
||||
response, err := c.client.Get(c.url)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer response.Body.Close()
|
||||
return response.StatusCode == http.StatusOK
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type JSONHelperStatusWriter struct{}
|
||||
|
||||
var _ domain.HelperStatusWriter = &JSONHelperStatusWriter{}
|
||||
|
||||
func NewJSONHelperStatusWriter() *JSONHelperStatusWriter {
|
||||
return &JSONHelperStatusWriter{}
|
||||
}
|
||||
|
||||
func (w *JSONHelperStatusWriter) Write(dataDir string, status domain.HelperStatus) error {
|
||||
return writeJSONAtomically(filepath.Join(dataDir, domain.HelperStatusFileName), status, readableFilePermissions)
|
||||
}
|
||||
|
||||
func (w *JSONHelperStatusWriter) Remove(dataDir string) (bool, error) {
|
||||
return removeFile(filepath.Join(dataDir, domain.HelperStatusFileName))
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type JSONRestartRequestStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var _ domain.RestartRequestStore = &JSONRestartRequestStore{}
|
||||
|
||||
func NewJSONRestartRequestStore(path string) *JSONRestartRequestStore {
|
||||
return &JSONRestartRequestStore{path: path}
|
||||
}
|
||||
|
||||
func (s *JSONRestartRequestStore) Write(request domain.RestartRequest) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
return writeJSONAtomically(s.path, request, statusFilePermissions)
|
||||
}
|
||||
|
||||
func (s *JSONRestartRequestStore) Read() (domain.RestartRequest, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
info, err := os.Lstat(s.path)
|
||||
if errors.Is(err, fs.ErrNotExist) || (err == nil && info.IsDir()) {
|
||||
return domain.RestartRequest{}, domain.ErrNoRestartRequest
|
||||
}
|
||||
if err != nil {
|
||||
return domain.RestartRequest{}, err
|
||||
}
|
||||
content, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return domain.RestartRequest{}, err
|
||||
}
|
||||
var request domain.RestartRequest
|
||||
return request, json.Unmarshal(content, &request)
|
||||
}
|
||||
|
||||
func (s *JSONRestartRequestStore) Delete() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
return removeIfPresent(s.path)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type JSONResultStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var _ domain.ResultStore = &JSONResultStore{}
|
||||
|
||||
func NewJSONResultStore(path string) *JSONResultStore {
|
||||
return &JSONResultStore{path: path}
|
||||
}
|
||||
|
||||
func (s *JSONResultStore) Read() (domain.Result, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
content, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return domain.Result{}, false
|
||||
}
|
||||
return decodeResult(content)
|
||||
}
|
||||
|
||||
func (s *JSONResultStore) Write(result domain.Result) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
return writeJSONAtomically(s.path, result, readableFilePermissions)
|
||||
}
|
||||
|
||||
func (s *JSONResultStore) Delete() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
return removeIfPresent(s.path)
|
||||
}
|
||||
|
||||
func decodeResult(content []byte) (domain.Result, bool) {
|
||||
var result domain.Result
|
||||
if err := json.Unmarshal(content, &result); err != nil || !result.IsKnown() {
|
||||
return domain.Result{}, false
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type JSONRunningInfoStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var _ domain.RunningInfoStore = &JSONRunningInfoStore{}
|
||||
|
||||
func NewJSONRunningInfoStore(path string) *JSONRunningInfoStore {
|
||||
return &JSONRunningInfoStore{path: path}
|
||||
}
|
||||
|
||||
func (s *JSONRunningInfoStore) Write(info domain.RunningInfo) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
return writeJSONAtomically(s.path, info, readableFilePermissions)
|
||||
}
|
||||
|
||||
func (s *JSONRunningInfoStore) Read() (domain.RunningInfo, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
linkInfo, err := os.Lstat(s.path)
|
||||
if err != nil || !linkInfo.Mode().IsRegular() {
|
||||
return domain.RunningInfo{}, false
|
||||
}
|
||||
content, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return domain.RunningInfo{}, false
|
||||
}
|
||||
var info domain.RunningInfo
|
||||
if err := json.Unmarshal(content, &info); err != nil || info.Version == "" || info.Pid == 0 {
|
||||
return domain.RunningInfo{}, false
|
||||
}
|
||||
return info, true
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//go:build linux
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const (
|
||||
privateTempDirPrefix = "renewcms-update-"
|
||||
readNoFollowFlags = os.O_RDONLY | syscall.O_NOFOLLOW | syscall.O_NONBLOCK
|
||||
createNoFollowFlags = os.O_WRONLY | os.O_CREATE | os.O_EXCL | syscall.O_NOFOLLOW
|
||||
directoryFlags = os.O_RDONLY | syscall.O_NOFOLLOW | syscall.O_DIRECTORY
|
||||
)
|
||||
|
||||
var errNotRegularFile = errors.New("not a regular file")
|
||||
|
||||
type NoFollowFilesystem struct{}
|
||||
|
||||
var _ domain.HelperFilesystem = &NoFollowFilesystem{}
|
||||
|
||||
func NewNoFollowFilesystem() *NoFollowFilesystem {
|
||||
return &NoFollowFilesystem{}
|
||||
}
|
||||
|
||||
func (f *NoFollowFilesystem) Lstat(path string) (domain.FileInfo, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return domain.FileInfo{}, err
|
||||
}
|
||||
mode := info.Mode()
|
||||
return domain.FileInfo{
|
||||
IsSymlink: mode&fs.ModeSymlink != 0,
|
||||
IsDir: mode.IsDir(),
|
||||
IsRegular: mode.IsRegular(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *NoFollowFilesystem) ReadFile(path string) ([]byte, error) {
|
||||
file, err := openRegularNoFollow(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
return io.ReadAll(file)
|
||||
}
|
||||
|
||||
func (f *NoFollowFilesystem) CreateExclusive(path string, content []byte, mode uint32) error {
|
||||
file, err := os.OpenFile(path, createNoFollowFlags, fs.FileMode(mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := file.Write(content); err != nil {
|
||||
file.Close()
|
||||
return err
|
||||
}
|
||||
return finishWrite(file, mode)
|
||||
}
|
||||
|
||||
func (f *NoFollowFilesystem) Copy(source string, destination string, mode uint32) error {
|
||||
input, err := openRegularNoFollow(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer input.Close()
|
||||
output, err := os.OpenFile(destination, createNoFollowFlags, fs.FileMode(mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(output, input); err != nil {
|
||||
output.Close()
|
||||
return err
|
||||
}
|
||||
return finishWrite(output, mode)
|
||||
}
|
||||
|
||||
func (f *NoFollowFilesystem) Rename(oldPath string, newPath string) error {
|
||||
return os.Rename(oldPath, newPath)
|
||||
}
|
||||
|
||||
func (f *NoFollowFilesystem) Remove(path string) error {
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
func (f *NoFollowFilesystem) RemoveAll(path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return os.Remove(path)
|
||||
}
|
||||
names, err := readDirectoryNames(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range names {
|
||||
if err := f.RemoveAll(filepath.Join(path, name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
func (f *NoFollowFilesystem) Chmod(path string, mode uint32) error {
|
||||
file, err := os.OpenFile(path, readNoFollowFlags, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
return file.Chmod(fs.FileMode(mode))
|
||||
}
|
||||
|
||||
func (f *NoFollowFilesystem) MakePrivateTempDir() (string, error) {
|
||||
return os.MkdirTemp("", privateTempDirPrefix)
|
||||
}
|
||||
|
||||
func openRegularNoFollow(path string) (*os.File, error) {
|
||||
file, err := os.OpenFile(path, readNoFollowFlags, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
file.Close()
|
||||
return nil, errNotRegularFile
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func finishWrite(file *os.File, mode uint32) error {
|
||||
if err := file.Chmod(fs.FileMode(mode)); err != nil {
|
||||
file.Close()
|
||||
return err
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
file.Close()
|
||||
return err
|
||||
}
|
||||
return file.Close()
|
||||
}
|
||||
|
||||
func readDirectoryNames(path string) ([]string, error) {
|
||||
directory, err := os.OpenFile(path, directoryFlags, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer directory.Close()
|
||||
return directory.Readdirnames(-1)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//go:build !linux
|
||||
|
||||
package update
|
||||
|
||||
import domain "RenewCMS/internal/domain/update"
|
||||
|
||||
type NoFollowFilesystem struct{}
|
||||
|
||||
var _ domain.HelperFilesystem = &NoFollowFilesystem{}
|
||||
|
||||
func NewNoFollowFilesystem() *NoFollowFilesystem {
|
||||
return &NoFollowFilesystem{}
|
||||
}
|
||||
|
||||
func (f *NoFollowFilesystem) Lstat(string) (domain.FileInfo, error) {
|
||||
return domain.FileInfo{}, domain.ErrHelperUnsupported
|
||||
}
|
||||
|
||||
func (f *NoFollowFilesystem) ReadFile(string) ([]byte, error) {
|
||||
return nil, domain.ErrHelperUnsupported
|
||||
}
|
||||
|
||||
func (f *NoFollowFilesystem) CreateExclusive(string, []byte, uint32) error {
|
||||
return domain.ErrHelperUnsupported
|
||||
}
|
||||
|
||||
func (f *NoFollowFilesystem) Copy(string, string, uint32) error {
|
||||
return domain.ErrHelperUnsupported
|
||||
}
|
||||
|
||||
func (f *NoFollowFilesystem) Rename(string, string) error {
|
||||
return domain.ErrHelperUnsupported
|
||||
}
|
||||
|
||||
func (f *NoFollowFilesystem) Remove(string) error {
|
||||
return domain.ErrHelperUnsupported
|
||||
}
|
||||
|
||||
func (f *NoFollowFilesystem) RemoveAll(string) error {
|
||||
return domain.ErrHelperUnsupported
|
||||
}
|
||||
|
||||
func (f *NoFollowFilesystem) Chmod(string, uint32) error {
|
||||
return domain.ErrHelperUnsupported
|
||||
}
|
||||
|
||||
func (f *NoFollowFilesystem) MakePrivateTempDir() (string, error) {
|
||||
return "", domain.ErrHelperUnsupported
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SystemClock struct{}
|
||||
|
||||
var _ domain.Clock = &SystemClock{}
|
||||
|
||||
func NewSystemClock() *SystemClock {
|
||||
return &SystemClock{}
|
||||
}
|
||||
|
||||
func (c *SystemClock) Now() time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (c *SystemClock) Sleep(duration time.Duration) {
|
||||
time.Sleep(duration)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"os"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
const systemdRuntimeDirectory = "/run/systemd/system"
|
||||
|
||||
type SystemMachineProbe struct{}
|
||||
|
||||
var _ domain.MachineProbe = &SystemMachineProbe{}
|
||||
|
||||
func NewSystemMachineProbe() *SystemMachineProbe {
|
||||
return &SystemMachineProbe{}
|
||||
}
|
||||
|
||||
func (p *SystemMachineProbe) OS() string {
|
||||
return runtime.GOOS
|
||||
}
|
||||
|
||||
func (p *SystemMachineProbe) SystemdRunning() bool {
|
||||
info, err := os.Stat(systemdRuntimeDirectory)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
func (p *SystemMachineProbe) IsRoot() bool {
|
||||
return os.Geteuid() == 0
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const systemctlCommand = "systemctl"
|
||||
|
||||
type SystemctlControl struct {
|
||||
service string
|
||||
}
|
||||
|
||||
var (
|
||||
_ domain.ServiceControl = &SystemctlControl{}
|
||||
_ domain.UnitManager = &SystemctlControl{}
|
||||
)
|
||||
|
||||
func NewSystemctlControl(service string) *SystemctlControl {
|
||||
return &SystemctlControl{service: service}
|
||||
}
|
||||
|
||||
func (c *SystemctlControl) Restart() error {
|
||||
return runSystemctl("restart", c.service)
|
||||
}
|
||||
|
||||
func (c *SystemctlControl) Status() (bool, error) {
|
||||
err := exec.Command(systemctlCommand, "is-active", "--quiet", c.service).Run()
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func (c *SystemctlControl) DaemonReload() error {
|
||||
return runSystemctl("daemon-reload")
|
||||
}
|
||||
|
||||
func (c *SystemctlControl) EnableNow(unit string) error {
|
||||
return runSystemctl("enable", "--now", unit)
|
||||
}
|
||||
|
||||
func (c *SystemctlControl) DisableNow(unit string) error {
|
||||
return runSystemctl("disable", "--now", unit)
|
||||
}
|
||||
|
||||
func runSystemctl(arguments ...string) error {
|
||||
output, err := exec.Command(systemctlCommand, arguments...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("systemctl %s: %w: %s", strings.Join(arguments, " "), err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/update"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
systemUnitDirectory = "/etc/systemd/system"
|
||||
unitFilePermissions = 0o644
|
||||
unitFileSuffix = ".service"
|
||||
)
|
||||
|
||||
type SystemdUnits struct {
|
||||
directory string
|
||||
}
|
||||
|
||||
var (
|
||||
_ domain.UnitWriter = &SystemdUnits{}
|
||||
_ domain.ServiceLocator = &SystemdUnits{}
|
||||
)
|
||||
|
||||
func NewSystemdUnits() *SystemdUnits {
|
||||
return NewSystemdUnitsIn(systemUnitDirectory)
|
||||
}
|
||||
|
||||
func NewSystemdUnitsIn(directory string) *SystemdUnits {
|
||||
return &SystemdUnits{directory: directory}
|
||||
}
|
||||
|
||||
func (u *SystemdUnits) Write(name string, content string) error {
|
||||
return os.WriteFile(filepath.Join(u.directory, name), []byte(content), unitFilePermissions)
|
||||
}
|
||||
|
||||
func (u *SystemdUnits) Remove(name string) (bool, error) {
|
||||
return removeFile(filepath.Join(u.directory, name))
|
||||
}
|
||||
|
||||
func (u *SystemdUnits) Locate(name string) (domain.ServiceDescription, bool) {
|
||||
content, err := os.ReadFile(filepath.Join(u.directory, name+unitFileSuffix))
|
||||
if err != nil {
|
||||
return domain.ServiceDescription{}, false
|
||||
}
|
||||
description := ParseServiceUnit(string(content))
|
||||
description.Name = name
|
||||
return description, true
|
||||
}
|
||||
|
||||
func (u *SystemdUnits) PortFromEnvironmentFile(path string) (string, bool) {
|
||||
if path == "" {
|
||||
return "", false
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return portFromEnvironment(string(content))
|
||||
}
|
||||
|
||||
func ParseServiceUnit(content string) domain.ServiceDescription {
|
||||
var description domain.ServiceDescription
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
key, value, found := strings.Cut(strings.TrimSpace(line), "=")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "ExecStart":
|
||||
description.BinaryPath = firstUnitPath(value, "@-:+!")
|
||||
case "EnvironmentFile":
|
||||
description.EnvironmentFile = firstUnitPath(value, "-")
|
||||
case "ReadWritePaths":
|
||||
if description.DataDir == "" {
|
||||
description.DataDir = firstUnitPath(value, "-+!")
|
||||
}
|
||||
}
|
||||
}
|
||||
return description
|
||||
}
|
||||
|
||||
func firstUnitPath(value string, prefixes string) string {
|
||||
fields := strings.Fields(value)
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Trim(strings.TrimLeft(fields[0], prefixes), `"`)
|
||||
}
|
||||
|
||||
func portFromEnvironment(content string) (string, bool) {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
key, value, found := strings.Cut(strings.TrimSpace(line), "=")
|
||||
if !found || strings.TrimSpace(key) != "PORT" {
|
||||
continue
|
||||
}
|
||||
port := strings.Trim(strings.TrimSpace(value), `"'`)
|
||||
if _, err := strconv.Atoi(port); err == nil {
|
||||
return port, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,22 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/update"
|
||||
import (
|
||||
"RenewCMS/internal/domain/update"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GetUpdateStatusUseCase struct {
|
||||
store update.StatusStore
|
||||
jobs update.JobStore
|
||||
helper update.HelperDetector
|
||||
currentVersion string
|
||||
status updateStatusBuilder
|
||||
helper update.HelperDetector
|
||||
}
|
||||
|
||||
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 NewGetUpdateStatusUseCase(store update.StatusStore, jobs update.JobStore, helper update.HelperDetector, results update.ResultStore, requests update.RestartRequestStore, currentVersion string, now func() time.Time) *GetUpdateStatusUseCase {
|
||||
return &GetUpdateStatusUseCase{
|
||||
status: updateStatusBuilder{store: store, jobs: jobs, results: results, requests: requests, currentVersion: currentVersion, now: now},
|
||||
helper: helper,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if job, found := jobs.Load(); found {
|
||||
status = status.WithJob(job)
|
||||
}
|
||||
status.CanApply = canApply
|
||||
return status
|
||||
return g.status.build(isOwner && g.helper.Installed())
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/update"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ManageBrowserUpdatesDependencies struct {
|
||||
Probe update.MachineProbe
|
||||
Locator update.ServiceLocator
|
||||
Units update.UnitWriter
|
||||
Manager update.UnitManager
|
||||
Status update.HelperStatusWriter
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type EnableBrowserUpdatesUseCase struct {
|
||||
dependencies ManageBrowserUpdatesDependencies
|
||||
}
|
||||
|
||||
type DisableBrowserUpdatesUseCase struct {
|
||||
dependencies ManageBrowserUpdatesDependencies
|
||||
}
|
||||
|
||||
func NewEnableBrowserUpdatesUseCase(dependencies ManageBrowserUpdatesDependencies) *EnableBrowserUpdatesUseCase {
|
||||
return &EnableBrowserUpdatesUseCase{dependencies: dependencies}
|
||||
}
|
||||
|
||||
func NewDisableBrowserUpdatesUseCase(dependencies ManageBrowserUpdatesDependencies) *DisableBrowserUpdatesUseCase {
|
||||
return &DisableBrowserUpdatesUseCase{dependencies: dependencies}
|
||||
}
|
||||
|
||||
func (e *EnableBrowserUpdatesUseCase) Enable(options update.InstallOptions) (string, error) {
|
||||
d := e.dependencies
|
||||
if err := update.CheckInstallEnvironment(d.Probe); err != nil {
|
||||
return "", err
|
||||
}
|
||||
resolved, err := e.resolve(options)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := e.install(resolved); err != nil {
|
||||
e.undo()
|
||||
return "", err
|
||||
}
|
||||
status := update.HelperStatus{
|
||||
Version: update.HelperStatusVersion,
|
||||
Service: resolved.Service,
|
||||
Binary: resolved.Binary,
|
||||
DataDir: resolved.DataDir,
|
||||
InstalledAt: d.Now().UTC(),
|
||||
}
|
||||
if err := d.Status.Write(resolved.DataDir, status); err != nil {
|
||||
e.undo()
|
||||
return "", err
|
||||
}
|
||||
return enabledMessage(resolved), nil
|
||||
}
|
||||
|
||||
func (e *EnableBrowserUpdatesUseCase) resolve(options update.InstallOptions) (update.InstallOptions, error) {
|
||||
service := firstNonEmpty(options.Service, update.DefaultService)
|
||||
description, found := e.dependencies.Locator.Locate(service)
|
||||
resolved := update.InstallOptions{
|
||||
Service: service,
|
||||
Binary: firstNonEmpty(options.Binary, description.BinaryPath),
|
||||
DataDir: firstNonEmpty(options.DataDir, description.DataDir),
|
||||
}
|
||||
if resolved.Binary == "" || (resolved.DataDir == "" && !found) {
|
||||
return resolved, update.ErrServiceNotFound
|
||||
}
|
||||
if resolved.DataDir == "" {
|
||||
return resolved, update.ErrDataDirNotFound
|
||||
}
|
||||
resolved.Port = e.port(options.Port, description)
|
||||
return resolved, update.ValidatePort(resolved.Port)
|
||||
}
|
||||
|
||||
func (e *EnableBrowserUpdatesUseCase) port(flag string, description update.ServiceDescription) string {
|
||||
if flag != "" {
|
||||
return flag
|
||||
}
|
||||
if port, found := e.dependencies.Locator.PortFromEnvironmentFile(description.EnvironmentFile); found {
|
||||
return port
|
||||
}
|
||||
return update.DefaultPort
|
||||
}
|
||||
|
||||
func (e *EnableBrowserUpdatesUseCase) install(options update.InstallOptions) error {
|
||||
d := e.dependencies
|
||||
layout := update.HelperLayout{DataDir: options.DataDir, BinaryPath: options.Binary}
|
||||
if err := d.Units.Write(update.ServiceUnitName, update.RenderServiceUnit(options)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.Units.Write(update.PathUnitName, update.RenderPathUnit(layout)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.Manager.DaemonReload(); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.Manager.EnableNow(update.PathUnitName)
|
||||
}
|
||||
|
||||
func (e *EnableBrowserUpdatesUseCase) undo() {
|
||||
d := e.dependencies
|
||||
_ = d.Manager.DisableNow(update.PathUnitName)
|
||||
_, _ = d.Units.Remove(update.ServiceUnitName)
|
||||
_, _ = d.Units.Remove(update.PathUnitName)
|
||||
_ = d.Manager.DaemonReload()
|
||||
}
|
||||
|
||||
func enabledMessage(options update.InstallOptions) string {
|
||||
lines := []string{
|
||||
"Browser updates are enabled for " + options.Service + ".",
|
||||
"Installed " + update.PathUnitName + " and " + update.ServiceUnitName + " (port " + options.Port + ") and wrote " + update.HelperStatusFileName + " in " + options.DataDir + ".",
|
||||
"To undo it, run: renewcms disable-browser-updates",
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (u *DisableBrowserUpdatesUseCase) Disable(options update.InstallOptions) (string, error) {
|
||||
d := u.dependencies
|
||||
if d.Probe.OS() != update.OSLinux || !d.Probe.SystemdRunning() {
|
||||
return notEnabledMessage, nil
|
||||
}
|
||||
if err := update.CheckInstallEnvironment(d.Probe); err != nil {
|
||||
return "", err
|
||||
}
|
||||
dataDir := firstNonEmpty(options.DataDir, u.locatedDataDir(options.Service))
|
||||
|
||||
_ = d.Manager.DisableNow(update.PathUnitName)
|
||||
removed := u.removeUnits()
|
||||
if dataDir == "" {
|
||||
return "", update.ErrDataDirNotFound
|
||||
}
|
||||
statusRemoved, err := d.Status.Remove(dataDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !removed && !statusRemoved {
|
||||
return notEnabledMessage, nil
|
||||
}
|
||||
if err := d.Manager.DaemonReload(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return disabledMessage, nil
|
||||
}
|
||||
|
||||
func (u *DisableBrowserUpdatesUseCase) locatedDataDir(service string) string {
|
||||
description, _ := u.dependencies.Locator.Locate(firstNonEmpty(service, update.DefaultService))
|
||||
return description.DataDir
|
||||
}
|
||||
|
||||
func (u *DisableBrowserUpdatesUseCase) removeUnits() bool {
|
||||
removedAny := false
|
||||
for _, name := range []string{update.PathUnitName, update.ServiceUnitName} {
|
||||
removed, err := u.dependencies.Units.Remove(name)
|
||||
removedAny = removedAny || (removed && err == nil)
|
||||
}
|
||||
return removedAny
|
||||
}
|
||||
|
||||
const (
|
||||
notEnabledMessage = "Browser updates were not enabled, nothing to remove."
|
||||
disabledMessage = "Browser updates are disabled. The restart request, the result, the staged files and the backups were left in place."
|
||||
)
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/update"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RecordRunningInfoUseCase struct {
|
||||
store update.RunningInfoStore
|
||||
version string
|
||||
pid int
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewRecordRunningInfoUseCase(store update.RunningInfoStore, version string, pid int, now func() time.Time) *RecordRunningInfoUseCase {
|
||||
return &RecordRunningInfoUseCase{store: store, version: version, pid: pid, now: now}
|
||||
}
|
||||
|
||||
func (r *RecordRunningInfoUseCase) Record() error {
|
||||
return r.store.Write(update.RunningInfo{Version: r.version, Pid: r.pid, StartedAt: r.now().UTC()})
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/update"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RequestRestartUseCase struct {
|
||||
jobs update.JobStore
|
||||
helper update.HelperDetector
|
||||
results update.ResultStore
|
||||
requests update.RestartRequestStore
|
||||
status updateStatusBuilder
|
||||
now func() time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewRequestRestartUseCase(store update.StatusStore, jobs update.JobStore, helper update.HelperDetector, results update.ResultStore, requests update.RestartRequestStore, currentVersion string, now func() time.Time) *RequestRestartUseCase {
|
||||
return &RequestRestartUseCase{
|
||||
jobs: jobs,
|
||||
helper: helper,
|
||||
results: results,
|
||||
requests: requests,
|
||||
status: updateStatusBuilder{store: store, jobs: jobs, results: results, requests: requests, currentVersion: currentVersion, now: now},
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RequestRestartUseCase) Request(userId uint32, kind string) (update.UpdateStatus, error) {
|
||||
if !r.helper.Installed() {
|
||||
return update.UpdateStatus{}, update.ErrNotPrepared
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
current := r.status.build(true)
|
||||
if err := refuseWhileBusy(current); err != nil {
|
||||
return update.UpdateStatus{}, err
|
||||
}
|
||||
request, err := r.requestFor(kind, userId)
|
||||
if err != nil {
|
||||
return update.UpdateStatus{}, err
|
||||
}
|
||||
if err := r.results.Delete(); err != nil {
|
||||
return update.UpdateStatus{}, err
|
||||
}
|
||||
if err := r.requests.Write(request); err != nil {
|
||||
return update.UpdateStatus{}, err
|
||||
}
|
||||
return r.status.build(true), nil
|
||||
}
|
||||
|
||||
func refuseWhileBusy(status update.UpdateStatus) error {
|
||||
switch status.State {
|
||||
case update.StateDownloading, update.StateVerifying:
|
||||
return update.ErrJobInProgress
|
||||
case update.StateRestarting:
|
||||
return update.ErrRestartPending
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RequestRestartUseCase) requestFor(kind string, userId uint32) (update.RestartRequest, error) {
|
||||
request := update.RestartRequest{
|
||||
Kind: update.RestartKindRestart,
|
||||
RequestedAt: r.now().UTC(),
|
||||
RequestedBy: userId,
|
||||
}
|
||||
if job, found := r.jobs.Load(); found && job.State == update.StateReady {
|
||||
request.Kind = update.RestartKindUpdate
|
||||
request.Tag = job.Tag
|
||||
}
|
||||
if kind != "" && kind != request.Kind {
|
||||
return update.RestartRequest{}, update.ErrRestartKindMismatch
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
@@ -12,6 +12,8 @@ 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
|
||||
@@ -29,6 +31,8 @@ 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
|
||||
@@ -52,9 +56,18 @@ func (f jobFailure) Error() string {
|
||||
|
||||
func NewStartUpdateDownloadUseCase(dependencies StartUpdateDownloadDependencies) *StartUpdateDownloadUseCase {
|
||||
return &StartUpdateDownloadUseCase{
|
||||
store: dependencies.Store,
|
||||
jobs: dependencies.Jobs,
|
||||
helper: dependencies.Helper,
|
||||
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,
|
||||
@@ -79,6 +92,9 @@ func (s *StartUpdateDownloadUseCase) Start() (update.UpdateStatus, error) {
|
||||
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
|
||||
@@ -95,10 +111,13 @@ func (s *StartUpdateDownloadUseCase) Start() (update.UpdateStatus, error) {
|
||||
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 := buildUpdateStatus(s.store, s.jobs, s.currentVersion, true)
|
||||
status := s.status.build(true)
|
||||
go s.run(job, binary, checksums, signature)
|
||||
|
||||
return status, nil
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/update"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type updateStatusBuilder struct {
|
||||
store update.StatusStore
|
||||
jobs update.JobStore
|
||||
results update.ResultStore
|
||||
requests update.RestartRequestStore
|
||||
currentVersion string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func (b updateStatusBuilder) build(canApply bool) update.UpdateStatus {
|
||||
status := b.baseStatus()
|
||||
if job, found := b.jobs.Load(); found {
|
||||
status = status.WithJob(job)
|
||||
}
|
||||
status.CanApply = canApply
|
||||
|
||||
request, requestErr := b.requests.Read()
|
||||
result, hasResult := b.results.Read()
|
||||
if hasResult {
|
||||
status.FinishedAt = result.FinishedAt
|
||||
}
|
||||
switch {
|
||||
case !errors.Is(requestErr, update.ErrNoRestartRequest):
|
||||
return status.WithRestartRequest(request)
|
||||
case hasResult:
|
||||
return status.WithResult(result, b.currentVersion, b.now())
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func (b updateStatusBuilder) baseStatus() update.UpdateStatus {
|
||||
if result, found := b.store.Load(); found {
|
||||
return update.NewStatus(b.currentVersion, &result)
|
||||
}
|
||||
return update.NewStatus(b.currentVersion, nil)
|
||||
}
|
||||
Reference in new issue
Block a user