Add --force-update: immediate signed-update check, stage + service restart
This commit is contained in:
@@ -149,6 +149,8 @@ OpenSSL; the matching public key lives in `internal/update/pubkey.go`
|
|||||||
(one-time setup: `openssl genpkey -algorithm ed25519 -out private.pem`,
|
(one-time setup: `openssl genpkey -algorithm ed25519 -out private.pem`,
|
||||||
`openssl pkey -in private.pem -pubout -out public.pem`; private key goes
|
`openssl pkey -in private.pem -pubout -out public.pem`; private key goes
|
||||||
to the `RELEASE_SIGNING_KEY` repo secret, public key is committed).
|
to the `RELEASE_SIGNING_KEY` repo secret, public key is committed).
|
||||||
|
`gpu-turnstile --force-update` checks immediately, stages the new binary
|
||||||
|
and restarts the running service (elevating via UAC only if needed).
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
|
|||||||
@@ -246,6 +246,11 @@ executable location as-is instead.
|
|||||||
idle the process exits with code 3 so the service recovery restarts it
|
idle the process exits with code 3 so the service recovery restarts it
|
||||||
on the new version. Interactive runs only log "restart to apply".
|
on the new version. Interactive runs only log "restart to apply".
|
||||||
`dev` builds and builds without an embedded public key never update.
|
`dev` builds and builds without an embedded public key never update.
|
||||||
|
- **`--force-update`** runs the same check immediately: it downloads,
|
||||||
|
verifies and stages a newer release, and if the service is running it
|
||||||
|
restarts it right away (otherwise the new version applies on next
|
||||||
|
start). On Windows it elevates via UAC only when the stage or restart
|
||||||
|
needs permissions the caller does not have.
|
||||||
- **Signing setup (one time)**: `openssl genpkey -algorithm ed25519 -out
|
- **Signing setup (one time)**: `openssl genpkey -algorithm ed25519 -out
|
||||||
private.pem`; `openssl pkey -in private.pem -pubout -out public.pem`.
|
private.pem`; `openssl pkey -in private.pem -pubout -out public.pem`.
|
||||||
Private key → repo secret `RELEASE_SIGNING_KEY`; public key → committed into
|
Private key → repo secret `RELEASE_SIGNING_KEY`; public key → committed into
|
||||||
|
|||||||
+123
-28
@@ -8,6 +8,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -45,8 +46,9 @@ func stdoutIsTerminal() bool {
|
|||||||
|
|
||||||
// parseFlags extracts -config <path> (or -config=<path>), the
|
// parseFlags extracts -config <path> (or -config=<path>), the
|
||||||
// --install-service / --remove-service switches, --no-copy, -h/--help,
|
// --install-service / --remove-service switches, --no-copy, -h/--help,
|
||||||
// -v/--version and the hidden --elevated-child marker from args.
|
// -v/--version, --force-update and the hidden --elevated-child marker from
|
||||||
func parseFlags(args []string) (configPath string, install, remove, noCopy, help, showVersion, elevatedChild bool, rest []string) {
|
// args.
|
||||||
|
func parseFlags(args []string) (configPath string, install, remove, noCopy, help, showVersion, forceUpdate, elevatedChild bool, rest []string) {
|
||||||
rest = args[:0]
|
rest = args[:0]
|
||||||
for i := 0; i < len(args); i++ {
|
for i := 0; i < len(args); i++ {
|
||||||
switch {
|
switch {
|
||||||
@@ -65,13 +67,15 @@ func parseFlags(args []string) (configPath string, install, remove, noCopy, help
|
|||||||
help = true
|
help = true
|
||||||
case args[i] == "-v" || args[i] == "--version" || args[i] == "-version":
|
case args[i] == "-v" || args[i] == "--version" || args[i] == "-version":
|
||||||
showVersion = true
|
showVersion = true
|
||||||
|
case args[i] == "--force-update" || args[i] == "-force-update":
|
||||||
|
forceUpdate = true
|
||||||
case args[i] == "--elevated-child":
|
case args[i] == "--elevated-child":
|
||||||
elevatedChild = true
|
elevatedChild = true
|
||||||
default:
|
default:
|
||||||
rest = append(rest, args[i])
|
rest = append(rest, args[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return configPath, install, remove, noCopy, help, showVersion, elevatedChild, rest
|
return configPath, install, remove, noCopy, help, showVersion, forceUpdate, elevatedChild, rest
|
||||||
}
|
}
|
||||||
|
|
||||||
// versionLine is printed at the top of every help and error screen.
|
// versionLine is printed at the top of every help and error screen.
|
||||||
@@ -84,6 +88,8 @@ Usage:
|
|||||||
gpu-turnstile --install-service [--no-copy] [-config path] install + start as a service
|
gpu-turnstile --install-service [--no-copy] [-config path] install + start as a service
|
||||||
gpu-turnstile --remove-service stop + uninstall the service
|
gpu-turnstile --remove-service stop + uninstall the service
|
||||||
gpu-turnstile -v | --version print just the version
|
gpu-turnstile -v | --version print just the version
|
||||||
|
gpu-turnstile --force-update check for a signed update now,
|
||||||
|
apply it and restart the service
|
||||||
gpu-turnstile -h | --help this help
|
gpu-turnstile -h | --help this help
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
@@ -114,12 +120,12 @@ func fatalUsage(format string, args ...any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
configPath, install, remove, noCopy, help, showVersion, elevatedChild, args := parseFlags(os.Args[1:])
|
configPath, install, remove, noCopy, help, showVersion, forceUpdate, elevatedChild, args := parseFlags(os.Args[1:])
|
||||||
if showVersion {
|
if showVersion {
|
||||||
fmt.Println(version)
|
fmt.Println(version)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
bare := configPath == "" && !install && !remove && !elevatedChild && len(args) == 0
|
bare := configPath == "" && !install && !remove && !forceUpdate && !elevatedChild && len(args) == 0
|
||||||
if help || (bare && stdoutIsTerminal()) {
|
if help || (bare && stdoutIsTerminal()) {
|
||||||
// Bare invocation in a terminal (e.g. double-clicked on Windows)
|
// Bare invocation in a terminal (e.g. double-clicked on Windows)
|
||||||
// shows the help instead of starting a proxy window with no visible
|
// shows the help instead of starting a proxy window with no visible
|
||||||
@@ -140,10 +146,14 @@ func main() {
|
|||||||
switch {
|
switch {
|
||||||
case install && remove:
|
case install && remove:
|
||||||
fatalUsage("error: --install-service and --remove-service are mutually exclusive")
|
fatalUsage("error: --install-service and --remove-service are mutually exclusive")
|
||||||
|
case forceUpdate && (install || remove):
|
||||||
|
fatalUsage("error: --force-update cannot be combined with --install-service/--remove-service")
|
||||||
case install:
|
case install:
|
||||||
os.Exit(serviceCommand(configPath, true, noCopy, elevatedChild))
|
os.Exit(serviceCommand(configPath, true, noCopy, elevatedChild))
|
||||||
case remove:
|
case remove:
|
||||||
os.Exit(serviceCommand(configPath, false, noCopy, elevatedChild))
|
os.Exit(serviceCommand(configPath, false, noCopy, elevatedChild))
|
||||||
|
case forceUpdate:
|
||||||
|
os.Exit(forceUpdateCommand(configPath, elevatedChild))
|
||||||
}
|
}
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
fatalUsage("error: unknown arguments: %s", strings.Join(args, " "))
|
fatalUsage("error: unknown arguments: %s", strings.Join(args, " "))
|
||||||
@@ -245,6 +255,102 @@ func newLogger(cfg config.Config) (*slog.Logger, io.Writer, io.Closer) {
|
|||||||
return log, out, closer
|
return log, out, closer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// waitForEnter keeps an elevated child's console window open until the
|
||||||
|
// user has read the output.
|
||||||
|
func waitForEnter() {
|
||||||
|
fmt.Print("\nPress Enter to close this window...")
|
||||||
|
bufio.NewReader(os.Stdin).ReadString('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// elevateAndMirror relaunches the current command elevated (UAC) and
|
||||||
|
// mirrors the child's exit code. verb is used in messages.
|
||||||
|
func elevateAndMirror(verb string) (int, bool) {
|
||||||
|
args := append(append([]string{}, os.Args[1:]...), "--elevated-child")
|
||||||
|
code, err := service.RelaunchElevated(args)
|
||||||
|
if errors.Is(err, service.ErrUserCancelled) {
|
||||||
|
fmt.Fprintln(os.Stderr, "gpu-turnstile: UAC prompt declined")
|
||||||
|
return 1, true
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "gpu-turnstile: could not elevate: %v\n", err)
|
||||||
|
return 1, true
|
||||||
|
}
|
||||||
|
if code != 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "gpu-turnstile %s failed in the elevated process (exit %d)\n", verb, code)
|
||||||
|
return code, true
|
||||||
|
}
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPermission reports whether err is a permission problem (Windows
|
||||||
|
// ERROR_ACCESS_DENIED, POSIX EACCES/EPERM, possibly wrapped).
|
||||||
|
func isPermission(err error) bool {
|
||||||
|
return errors.Is(err, fs.ErrPermission) || strings.Contains(strings.ToLower(err.Error()), "access is denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
// forceUpdateCommand checks for a signed update immediately, stages it if
|
||||||
|
// newer, and restarts the service when it is running so the new binary
|
||||||
|
// takes effect. Staging into a system directory and restarting a service
|
||||||
|
// need admin rights; instead of prompting unconditionally, permission
|
||||||
|
// failures trigger the UAC relaunch so a dev copy in a user-writable
|
||||||
|
// directory updates without a prompt.
|
||||||
|
func forceUpdateCommand(configPath string, elevatedChild bool) int {
|
||||||
|
if elevatedChild {
|
||||||
|
defer waitForEnter()
|
||||||
|
}
|
||||||
|
cfg, err := loadMergedConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n\ngpu-turnstile: %v\n", versionLine(), err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
exePath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "gpu-turnstile: cannot locate executable: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
log, _, logCloser := newLogger(cfg)
|
||||||
|
defer logCloser.Close()
|
||||||
|
u := &update.Updater{Repo: cfg.UpdateRepo, Asset: cfg.UpdateAsset, Version: version, Log: log}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
staged, err := u.Check(ctx, exePath)
|
||||||
|
if err != nil && isPermission(err) && !service.Elevated() {
|
||||||
|
code, _ := elevateAndMirror("--force-update")
|
||||||
|
if code == 0 {
|
||||||
|
fmt.Println("update applied (elevated)")
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n\ngpu-turnstile: update check failed: %v\n", versionLine(), err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if !staged {
|
||||||
|
fmt.Printf("%s is up to date\n", versionLine())
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: update staged\n", versionLine())
|
||||||
|
restarted, err := service.RestartIfRunning()
|
||||||
|
if err != nil && isPermission(err) && !service.Elevated() {
|
||||||
|
code, _ := elevateAndMirror("--force-update")
|
||||||
|
if code == 0 {
|
||||||
|
fmt.Println("update applied (elevated)")
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "gpu-turnstile: update staged but service restart failed: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if restarted {
|
||||||
|
fmt.Println("service restarted on the new version")
|
||||||
|
} else {
|
||||||
|
fmt.Println("no running service; the new version applies on next start")
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// serviceCommand installs (copyBin = register the canonical-layout copy)
|
// serviceCommand installs (copyBin = register the canonical-layout copy)
|
||||||
// or removes the service and reports the result. On Windows, when the
|
// or removes the service and reports the result. On Windows, when the
|
||||||
// shell is not elevated, the command relaunches itself through a UAC
|
// shell is not elevated, the command relaunches itself through a UAC
|
||||||
@@ -252,27 +358,22 @@ func newLogger(cfg config.Config) (*slog.Logger, io.Writer, io.Closer) {
|
|||||||
// waits for a keypress so its console window does not flash closed before
|
// waits for a keypress so its console window does not flash closed before
|
||||||
// the output can be read.
|
// the output can be read.
|
||||||
func serviceCommand(configPath string, install, noCopy, elevatedChild bool) int {
|
func serviceCommand(configPath string, install, noCopy, elevatedChild bool) int {
|
||||||
verb, done := "remove", "removed"
|
verb, doneVerb := "remove", "removed"
|
||||||
if install {
|
if install {
|
||||||
verb, done = "install", "installed"
|
verb, doneVerb = "install", "installed"
|
||||||
|
}
|
||||||
|
if elevatedChild {
|
||||||
|
defer waitForEnter()
|
||||||
}
|
}
|
||||||
if !service.Elevated() {
|
if !service.Elevated() {
|
||||||
args := append(append([]string{}, os.Args[1:]...), "--elevated-child")
|
code, done := elevateAndMirror(verb)
|
||||||
code, err := service.RelaunchElevated(args)
|
if done && code != 0 {
|
||||||
if errors.Is(err, service.ErrUserCancelled) {
|
|
||||||
fmt.Fprintln(os.Stderr, "gpu-turnstile: UAC prompt declined")
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "gpu-turnstile: could not elevate: %v\n", err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
if code != 0 {
|
|
||||||
fmt.Fprintf(os.Stderr, "gpu-turnstile service %s failed in the elevated process (exit %d)\n", verb, code)
|
|
||||||
return code
|
return code
|
||||||
}
|
}
|
||||||
fmt.Printf("service %s: %s (elevated)\n", service.Name, done)
|
if done {
|
||||||
return 0
|
fmt.Printf("service %s: %s (elevated)\n", service.Name, doneVerb)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
if install {
|
if install {
|
||||||
@@ -284,17 +385,11 @@ func serviceCommand(configPath string, install, noCopy, elevatedChild bool) int
|
|||||||
} else {
|
} else {
|
||||||
err = service.Remove()
|
err = service.Remove()
|
||||||
}
|
}
|
||||||
if elevatedChild {
|
|
||||||
defer func() {
|
|
||||||
fmt.Print("\nPress Enter to close this window...")
|
|
||||||
bufio.NewReader(os.Stdin).ReadString('\n')
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "gpu-turnstile service %s: %v\n", verb, err)
|
fmt.Fprintf(os.Stderr, "gpu-turnstile service %s: %v\n", verb, err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
fmt.Printf("service %s: %s\n", service.Name, done)
|
fmt.Printf("service %s: %s\n", service.Name, doneVerb)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -186,3 +186,16 @@ func Remove() error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RestartIfRunning restarts the systemd unit when it is active (used after
|
||||||
|
// a forced update staged a new binary). Reports whether a restart
|
||||||
|
// happened. An inactive or missing unit is not an error. Needs root.
|
||||||
|
func RestartIfRunning() (bool, error) {
|
||||||
|
if err := exec.Command("systemctl", "is-active", "--quiet", Name+".service").Run(); err != nil {
|
||||||
|
return false, nil // inactive or not installed
|
||||||
|
}
|
||||||
|
if out, err := exec.Command("systemctl", "restart", Name+".service").CombinedOutput(); err != nil {
|
||||||
|
return false, fmt.Errorf("systemctl restart (run as root): %w (%s)", err, out)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,3 +40,6 @@ func Elevated() bool { return true }
|
|||||||
|
|
||||||
// RelaunchElevated is unsupported on non-Windows, non-Linux platforms.
|
// RelaunchElevated is unsupported on non-Windows, non-Linux platforms.
|
||||||
func RelaunchElevated([]string) (int, error) { return 0, errUnsupported }
|
func RelaunchElevated([]string) (int, error) { return 0, errUnsupported }
|
||||||
|
|
||||||
|
// RestartIfRunning is a no-op on platforms without service integration.
|
||||||
|
func RestartIfRunning() (bool, error) { return false, nil }
|
||||||
|
|||||||
@@ -351,3 +351,48 @@ func configuredLogFile(configPath string) string {
|
|||||||
}
|
}
|
||||||
return values["LOG_FILE"]
|
return values["LOG_FILE"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RestartIfRunning restarts the service when it is installed and running
|
||||||
|
// (used after a forced update staged a new binary). Reports whether a
|
||||||
|
// restart happened. A service that is not installed or not running is not
|
||||||
|
// an error. Needs elevation.
|
||||||
|
func RestartIfRunning() (bool, error) {
|
||||||
|
m, err := mgr.Connect()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("connect to service manager (run as administrator): %w", err)
|
||||||
|
}
|
||||||
|
defer m.Disconnect()
|
||||||
|
s, err := m.OpenService(Name)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil // not installed
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
st, err := s.Query()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("query service: %w", err)
|
||||||
|
}
|
||||||
|
if st.State != svc.Running && st.State != svc.StartPending {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if _, err := s.Control(svc.Stop); err != nil {
|
||||||
|
return false, fmt.Errorf("stop service: %w", err)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(30 * time.Second)
|
||||||
|
for {
|
||||||
|
st, err = s.Query()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("query service: %w", err)
|
||||||
|
}
|
||||||
|
if st.State == svc.Stopped {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return false, fmt.Errorf("service did not stop within 30s")
|
||||||
|
}
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if err := s.Start(); err != nil {
|
||||||
|
return false, fmt.Errorf("start service: %w", err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user