feat: restart to apply update, privileged helper, rollback

This commit is contained in:
fsylvain committed 2026-10-01 21:36:40 +02:00
1 parent 70460e4170
commit 54e8fd5fed
63 files changed
+8635 -164

No files matched your search

+1
View File
@@ -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 {
+138
View File
@@ -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) + `"`
}
+63
View File
@@ -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[:])
}
+158
View File
@@ -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
}