all: implement self-upgrade flag from API

So upgrading don't have to be initiated manually, helping large
deployments to upgrade to latest ctrld version easily.
This commit is contained in:
Cuong Manh Le
2025-03-17 20:44:03 +07:00
committed by Cuong Manh Le
parent f27cbe3525
commit c60cf33af3
9 changed files with 175 additions and 4 deletions

View File

@@ -25,7 +25,7 @@ import (
"sync/atomic"
"time"
"github.com/Masterminds/semver"
"github.com/Masterminds/semver/v3"
"github.com/cuonglm/osinfo"
"github.com/go-playground/validator/v10"
"github.com/kardianos/service"

View File

@@ -11,6 +11,7 @@ import (
"net/netip"
"net/url"
"os"
"os/exec"
"runtime"
"slices"
"sort"
@@ -21,6 +22,7 @@ import (
"syscall"
"time"
"github.com/Masterminds/semver/v3"
"github.com/kardianos/service"
"github.com/rs/zerolog"
"github.com/spf13/viper"
@@ -304,6 +306,16 @@ func (p *prog) apiConfigReload() {
logger := mainLog.Load().With().Str("mode", "api-reload").Logger()
logger.Debug().Msg("starting custom config reload timer")
lastUpdated := time.Now().Unix()
curVerStr := curVersion()
curVer, err := semver.NewVersion(curVerStr)
isStable := curVer != nil && curVer.Prerelease() == ""
if err != nil || !isStable {
l := mainLog.Load().Warn()
if err != nil {
l = l.Err(err)
}
l.Msgf("current version is not stable, skipping self-upgrade: %s", curVerStr)
}
doReloadApiConfig := func(forced bool, logger zerolog.Logger) {
resolverConfig, err := controld.FetchResolverConfig(cdUID, rootCmd.Version, cdDev)
@@ -313,6 +325,11 @@ func (p *prog) apiConfigReload() {
return
}
// Performing self-upgrade check for production version.
if isStable {
selfUpgradeCheck(resolverConfig.Ctrld.VersionTarget, curVer, &logger)
}
if resolverConfig.DeactivationPin != nil {
newDeactivationPin := *resolverConfig.DeactivationPin
curDeactivationPin := cdDeactivationPin.Load()
@@ -1422,6 +1439,46 @@ func selfUninstallCheck(uninstallErr error, p *prog, logger zerolog.Logger) {
}
}
// selfUpgradeCheck checks if the version target vt is greater
// than the current one cv, perform self-upgrade then.
//
// The callers must ensure curVer and logger are non-nil.
func selfUpgradeCheck(vt string, cv *semver.Version, logger *zerolog.Logger) {
if vt == "" {
logger.Debug().Msg("no version target set, skipped checking self-upgrade")
return
}
vts := vt
if !strings.HasPrefix(vts, "v") {
vts = "v" + vts
}
targetVer, err := semver.NewVersion(vts)
if err != nil {
logger.Warn().Err(err).Msgf("invalid target version, skipped self-upgrade: %s", vt)
return
}
if !targetVer.GreaterThan(cv) {
logger.Debug().
Str("target", vt).
Str("current", cv.String()).
Msgf("target version is not greater than current one, skipped self-upgrade")
return
}
exe, err := os.Executable()
if err != nil {
mainLog.Load().Error().Err(err).Msg("failed to get executable path, skipped self-upgrade")
return
}
cmd := exec.Command(exe, "upgrade", "prod", "-vv")
cmd.SysProcAttr = sysProcAttrForSelfUpgrade()
if err := cmd.Start(); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to start self-upgrade")
return
}
mainLog.Load().Debug().Msgf("self-upgrade triggered, version target: %s", vts)
}
// leakOnUpstreamFailure reports whether ctrld should initiate a recovery flow
// when upstream failures occur.
func (p *prog) leakOnUpstreamFailure() bool {

View File

@@ -0,0 +1,12 @@
//go:build !windows
package cli
import (
"syscall"
)
// sysProcAttrForSelfUpgrade returns *syscall.SysProcAttr instance for running self-upgrade command.
func sysProcAttrForSelfUpgrade() *syscall.SysProcAttr {
return &syscall.SysProcAttr{Setsid: true}
}

View File

@@ -0,0 +1,18 @@
package cli
import (
"syscall"
)
// From: https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags?redirectedfrom=MSDN
// SYSCALL_CREATE_NO_WINDOW set flag to run process without a console window.
const SYSCALL_CREATE_NO_WINDOW = 0x08000000
// sysProcAttrForSelfUpgrade returns *syscall.SysProcAttr instance for running self-upgrade command.
func sysProcAttrForSelfUpgrade() *syscall.SysProcAttr {
return &syscall.SysProcAttr{
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | SYSCALL_CREATE_NO_WINDOW,
HideWindow: true,
}
}

View File

@@ -4,10 +4,12 @@ import (
"bytes"
"errors"
"fmt"
"io"
"os"
"os/exec"
"runtime"
"github.com/coreos/go-systemd/v22/unit"
"github.com/kardianos/service"
"github.com/Control-D-Inc/ctrld/internal/router"
@@ -132,6 +134,59 @@ func (s *systemd) Status() (service.Status, error) {
return s.Service.Status()
}
func (s *systemd) Start() error {
const systemdUnitFile = "/etc/systemd/system/ctrld.service"
f, err := os.Open(systemdUnitFile)
if err != nil {
return err
}
defer f.Close()
if opts, change := ensureSystemdKillMode(f); change {
mode := os.FileMode(0644)
buf, err := io.ReadAll(unit.Serialize(opts))
if err != nil {
return err
}
if err := os.WriteFile(systemdUnitFile, buf, mode); err != nil {
return err
}
if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil {
return fmt.Errorf("systemctl daemon-reload failed: %w\n%s", err, string(out))
}
mainLog.Load().Debug().Msg("set KillMode=process successfully")
}
return s.Service.Start()
}
// ensureSystemdKillMode ensure systemd unit file is configured with KillMode=process.
// This is necessary for running self-upgrade flow.
func ensureSystemdKillMode(r io.Reader) (opts []*unit.UnitOption, change bool) {
opts, err := unit.DeserializeOptions(r)
if err != nil {
mainLog.Load().Error().Err(err).Msg("failed to deserialize options")
return
}
change = true
needKillModeOpt := true
killModeOpt := unit.NewUnitOption("Service", "KillMode", "process")
for _, opt := range opts {
if opt.Match(killModeOpt) {
needKillModeOpt = false
change = false
break
}
if opt.Section == killModeOpt.Section && opt.Name == killModeOpt.Name {
opt.Value = killModeOpt.Value
needKillModeOpt = false
break
}
}
if needKillModeOpt {
opts = append(opts, killModeOpt)
}
return opts, change
}
func newLaunchd(s service.Service) *launchd {
return &launchd{
Service: s,

28
cmd/cli/service_test.go Normal file
View File

@@ -0,0 +1,28 @@
package cli
import (
"strings"
"testing"
)
func Test_ensureSystemdKillMode(t *testing.T) {
tests := []struct {
name string
unitFile string
wantChange bool
}{
{"no KillMode", "[Service]\nExecStart=/bin/sleep 1", true},
{"not KillMode=process", "[Service]\nExecStart=/bin/sleep 1\nKillMode=mixed", true},
{"KillMode=process", "[Service]\nExecStart=/bin/sleep 1\nKillMode=process", false},
{"invalid unit file", "[Service\nExecStart=/bin/sleep 1\nKillMode=process", false},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if _, change := ensureSystemdKillMode(strings.NewReader(tc.unitFile)); tc.wantChange != change {
t.Errorf("ensureSystemdKillMode(%q) = %v, want %v", tc.unitFile, change, tc.wantChange)
}
})
}
}