Skip to content

Commit 253a75e

Browse files
committed
updating the server in place by clicking a button.
1 parent 42d605d commit 253a75e

6 files changed

Lines changed: 253 additions & 6 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
users.json
22
pyramid-exe
33
pyramid-bin
4+
pyramid-old-binary
45
management.jsonl
56
settings.json
67
.env

handler.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,28 @@ func rootUserSetupHandler(w http.ResponseWriter, r *http.Request) {
502502
rootUserSetupPage().Render(r.Context(), w)
503503
}
504504

505+
func updateHandler(w http.ResponseWriter, r *http.Request) {
506+
loggedUser, _ := global.GetLoggedUser(r)
507+
508+
if !pyramid.IsRoot(loggedUser) {
509+
http.Error(w, "unauthorized", 403)
510+
return
511+
}
512+
513+
if r.Method == http.MethodPost {
514+
// if the update is successful the process will restart so this function will never return
515+
if err := performUpdateInPlace(); err != nil {
516+
log.Error().Err(err).Msg("update failed")
517+
http.Error(w, err.Error(), 500)
518+
return
519+
}
520+
521+
// if we reach here, the update failed to restart
522+
http.Error(w, "unexpected: update done, but couldn't restart the server (or something else)", 500)
523+
return
524+
}
525+
}
526+
505527
func forumHandler(w http.ResponseWriter, r *http.Request) {
506528
fmt.Fprintf(w, `<!doctype html>
507529
<html>

justfile

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ dev:
22
fd 'go|templ' | entr -r bash -c 'just templ && go build -o ./pyramid-exe && godotenv ./pyramid-exe'
33

44
build: templ
5-
CC=musl-gcc go build -ldflags='-linkmode external -extldflags "-static"' -o ./pyramid-exe
5+
#!/bin/bash
6+
7+
# set the global variable `currentVersion` to the latest git tag if we're in it, otherwise use the name of the latest tag + the first 8 characters of the current commit
8+
VERSION=$(git describe --tags --exact-match 2>/dev/null || echo "$(git describe --tags --abbrev=0)-$(git rev-parse --short=8 HEAD)")
9+
10+
# build with musl for maximum compatibility everywhere
11+
CC=musl-gcc go build -ldflags="-X main.currentVersion=$VERSION -linkmode external -extldflags \"-static\"" -o ./pyramid-exe
612

713
templ:
814
templ generate

main.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ func main() {
5151
}
5252
defer global.End()
5353

54+
// start periodic version checking
55+
go func() {
56+
for {
57+
fetchLatestVersion()
58+
time.Sleep(time.Hour * 3)
59+
}
60+
}()
61+
5462
pyramid.AbsoluteKey = global.Settings.RelayInternalSecretKey.Public()
5563

5664
if err := pyramid.LoadManagement(); err != nil {
@@ -80,6 +88,7 @@ func main() {
8088
relay.Router().HandleFunc("/cleanup", cleanupStuffFromExcludedUsersHandler)
8189
relay.Router().HandleFunc("/reports", reportsViewerHandler)
8290
relay.Router().HandleFunc("/settings", settingsHandler)
91+
relay.Router().HandleFunc("/update", updateHandler)
8392
relay.Router().HandleFunc("/icon/{relayId}", iconHandler)
8493
relay.Router().HandleFunc("/forum/", forumHandler)
8594
relay.Router().Handle("/static/", http.FileServer(http.FS(static)))
@@ -284,19 +293,20 @@ func main() {
284293
}
285294

286295
var (
287-
restarting = errors.New("restarting")
288-
restartCancel func()
296+
restarting = errors.New("::restarting::")
297+
updating = errors.New("::updating::")
298+
cancelStartContext context.CancelCauseFunc
289299
)
290300

291301
func restartSoon() {
292302
log.Info().Msg("restarting in 1 second")
293303
time.Sleep(time.Second * 1)
294-
restartCancel()
304+
cancelStartContext(restarting)
295305
}
296306

297307
func start() {
298-
ctx, cancelWithCause := context.WithCancelCause(context.Background())
299-
restartCancel = func() { cancelWithCause(restarting) }
308+
var ctx context.Context
309+
ctx, cancelStartContext = context.WithCancelCause(context.Background())
300310

301311
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
302312
defer cancel()

settings.templ

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,66 @@ templ settingsPage(loggedUser nostr.PubKey) {
365365
clear stuff from excluded users
366366
</a>
367367
</div>
368+
<!-- update Section -->
369+
<div
370+
class="mt-8"
371+
x-data={ `{
372+
updating: false,
373+
updateResult: '',
374+
async performUpdate() {
375+
this.updating = true;
376+
this.updateResult = '';
377+
try {
378+
const response = await fetch('/update', {
379+
method: 'POST',
380+
headers: {
381+
'Content-Type': 'application/x-www-form-urlencoded',
382+
}
383+
});
384+
const result = await response.text();
385+
this.updateResult = 'update failed: ' + result;
386+
} catch (error) {
387+
if (error.message === 'Failed to fetch') {
388+
// this means we have probably succeeded
389+
this.updateResult = 'update successful (probably). the page will refresh in 10 seconds.'
390+
setTimeout(() => location.reload(), 10000)
391+
return
392+
}
393+
this.updateResult = 'fetch call failed: ' + error.message;
394+
} finally {
395+
this.updating = false;
396+
}
397+
},
398+
versionToNumber (versionName) {
399+
if (versionName.startsWith('v')) versionName = versionName.slice(1)
400+
return versionName.split('.').map((n, i) => parseInt(n) * 10**(10-i*3)).reduce((acc, n) => acc + n, 0)
401+
}
402+
}` }
403+
>
404+
<h3 class="text-lg font-semibold dark:text-stone-200 mb-3">update</h3>
405+
<p class="text-sm text-stone-600 dark:text-stone-400 mb-4">
406+
<div>current version: <span class="text-stone-600 dark:text-stone-400" x-ref="currentVersion">{ currentVersion }</span></div>
407+
<div>latest version: <span class="text-stone-600 dark:text-stone-400" x-ref="latestVersion"><a href={ templ.SafeURL("https://github.com/fiatjaf/pyramid/releases/tag/" + latestVersion.name) } target="_blank">{ latestVersion.name }</a></span></div>
408+
</p>
409+
<div class="mt-2">
410+
<button
411+
type="button"
412+
@click="confirm('this will download the new binary from github, replace the current one and restart the server, are you ok to continue?') ? performUpdate() : null"
413+
:disabled="$refs.latestVersion.innerText === '' || versionToNumber($refs.latestVersion.innerText) <= versionToNumber($refs.currentVersion.innerText) || updating"
414+
class="inline-block px-4 py-2 rounded bg-emerald-600 hover:bg-emerald-700 disabled:bg-stone-400 text-white font-medium"
415+
>
416+
<span x-show="!updating">update pyramid</span>
417+
<span x-show="updating">updating...</span>
418+
</button>
419+
<div
420+
x-show="updateResult"
421+
x-transition
422+
class="mt-2 text-sm"
423+
:class="updateResult.includes('failed') ? 'text-red-600 dark:text-red-400' : 'text-emerald-800 dark:text-emerald-300'"
424+
x-text="updateResult"
425+
></div>
426+
</div>
427+
</div>
368428
</div>
369429
}
370430
}

updates.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"os"
9+
"path/filepath"
10+
"syscall"
11+
"time"
12+
13+
"github.com/fiatjaf/pyramid/global"
14+
)
15+
16+
// this is set at build time to something else based on git
17+
var currentVersion string = "dev"
18+
19+
type releaseVersion struct {
20+
name string
21+
binaryURL string
22+
}
23+
24+
var latestVersion releaseVersion
25+
26+
func fetchLatestVersion() {
27+
client := &http.Client{Timeout: 10 * time.Second}
28+
resp, err := client.Get("https://api.github.com/repos/fiatjaf/pyramid/releases/latest")
29+
if err != nil {
30+
log.Error().Err(err).Msg("failed to fetch latest release from github")
31+
return
32+
}
33+
defer resp.Body.Close()
34+
35+
if resp.StatusCode != http.StatusOK {
36+
log.Error().Int("status", resp.StatusCode).Msg("github api returned non-200 status")
37+
return
38+
}
39+
40+
body, err := io.ReadAll(resp.Body)
41+
if err != nil {
42+
log.Error().Err(err).Msg("failed to read github api response")
43+
return
44+
}
45+
46+
var release struct {
47+
TagName string `json:"tag_name"`
48+
Name string `json:"name"`
49+
Assets []struct {
50+
Name string `json:"name"`
51+
URL string `json:"browser_download_url"`
52+
} `json:"assets"`
53+
}
54+
if err := json.Unmarshal(body, &release); err != nil {
55+
log.Error().Err(err).Msg("failed to parse github api response")
56+
return
57+
}
58+
59+
// find the pyramid-exe asset
60+
var binaryURL string
61+
for _, asset := range release.Assets {
62+
if asset.Name == "pyramid-exe" {
63+
binaryURL = asset.URL
64+
break
65+
}
66+
}
67+
if binaryURL == "" {
68+
log.Error().Msg("pyramid-exe asset not found in latest release")
69+
return
70+
}
71+
72+
latestVersion = releaseVersion{
73+
name: release.TagName,
74+
binaryURL: binaryURL,
75+
}
76+
log.Info().Str("version", latestVersion.name).Msg("fetched latest version from github")
77+
}
78+
79+
func performUpdateInPlace() error {
80+
log.Info().Str("version", latestVersion.name).Msg("performing in-place update")
81+
if latestVersion.binaryURL == "" {
82+
return fmt.Errorf("no update available")
83+
}
84+
85+
currentBinary, err := os.Executable()
86+
if err != nil {
87+
return fmt.Errorf("failed to get executable path: %w", err)
88+
}
89+
90+
// download the new binary
91+
log.Info().Str("url", latestVersion.binaryURL).Msg("downloading version for update")
92+
client := &http.Client{Timeout: 30 * time.Second}
93+
resp, err := client.Get(latestVersion.binaryURL)
94+
if err != nil {
95+
return fmt.Errorf("failed to download binary: %w", err)
96+
}
97+
defer resp.Body.Close()
98+
if resp.StatusCode != 200 {
99+
b, _ := io.ReadAll(resp.Body)
100+
body := string(b)
101+
if len(body) > 200 {
102+
body = body[0:199] + "…"
103+
}
104+
log.Warn().Str("body", body).Int("status", resp.StatusCode).Msg("github failed to serve us the binary again")
105+
return fmt.Errorf("downloading the new binary from github failed with status %d", resp.StatusCode)
106+
}
107+
// save the new binary to a stable path (overwrite it if it exists)
108+
tempPath := fmt.Sprintf("pyramid-update-%s", latestVersion.name)
109+
tempFile, err := os.Create(tempPath)
110+
if err != nil {
111+
return fmt.Errorf("failed to create temp file: %w", err)
112+
}
113+
defer os.Remove(tempPath)
114+
if _, err := io.Copy(tempFile, resp.Body); err != nil {
115+
tempFile.Close()
116+
return fmt.Errorf("failed to write binary: %w", err)
117+
}
118+
tempFile.Close()
119+
120+
// use rename for atomic replacement
121+
log.Info().Msg("replacing binary with new version")
122+
if err := os.Rename(currentBinary, "pyramid-old-binary"); err != nil {
123+
return fmt.Errorf("replace failed: %w", err)
124+
}
125+
if err := os.Rename(tempPath, currentBinary); err != nil {
126+
return fmt.Errorf("replace failed: %w", err)
127+
}
128+
// ensure executable permissions on the final binary
129+
if err := os.Chmod(currentBinary, 0755); err != nil {
130+
return fmt.Errorf("chmod failed: %w", err)
131+
}
132+
133+
log.Info().Msg("restarting process with new binary...")
134+
// get the absolute path (syscall.Exec requires absolute path)
135+
absPath, err := filepath.Abs(currentBinary)
136+
if err != nil {
137+
return fmt.Errorf("failed to get absolute path: %w", err)
138+
}
139+
140+
// execute the new binary, replacing current process
141+
// this call does not return if successful, therefore we must perform a graceful deinitialization of all things
142+
cancelStartContext(updating)
143+
global.End()
144+
err = syscall.Exec(absPath, append([]string{absPath}, os.Args[1:]...), os.Environ())
145+
146+
// if we reach here, exec failed
147+
return fmt.Errorf("exec failed: %w", err)
148+
}

0 commit comments

Comments
 (0)