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

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