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
|
||||
}
|
||||
Reference in new issue
Block a user