Files
nadir-agent/cmd/server/update.go
T
urania eeb85bbd8f
build-and-release / release (push) Successful in 2m6s
feat: update on cli
2026-06-22 17:51:19 +02:00

144 lines
3.7 KiB
Go

package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"runtime"
"strings"
"time"
"nadir/internal/config"
)
// updateCmd implements `nadir update`: hit the configured Gitea repo's
// releases/latest, pick the asset for the host's GOARCH, atomically replace
// /usr/local/bin/nadir (or wherever the running binary lives), and restart
// the systemd unit so the new code takes effect.
func updateCmd(_ []string) error {
configPath, err := resolveConfigPath()
if err != nil {
return err
}
cfg, err := config.Load(configPath)
if err != nil {
return err
}
if cfg.Server.ReleaseRepo == "" {
return fmt.Errorf("server.release_repo not set in %s", configPath)
}
apiURL, err := releaseAPIURL(cfg.Server.ReleaseRepo)
if err != nil {
return err
}
suffix := "linux-" + runtime.GOARCH
fmt.Printf("querying %s ...\n", apiURL)
rel, err := fetchLatestRelease(apiURL)
if err != nil {
return err
}
var assetURL, assetName string
for _, a := range rel.Assets {
if strings.HasSuffix(a.Name, suffix) {
assetURL, assetName = a.URL, a.Name
break
}
}
if assetURL == "" {
return fmt.Errorf("no %s asset in release %s", suffix, rel.TagName)
}
exe, err := os.Executable()
if err != nil {
return err
}
fmt.Printf("downloading %s (%s) ...\n", assetName, rel.TagName)
tmp := exe + ".tmp"
if err := download(assetURL, tmp); err != nil {
os.Remove(tmp)
return err
}
// Atomic on the same filesystem; replaces the on-disk file without
// disturbing the still-running process (its inode stays alive).
if err := os.Rename(tmp, exe); err != nil {
os.Remove(tmp)
return err
}
fmt.Printf("installed %s at %s\n", rel.TagName, exe)
fmt.Println("restarting service ...")
if err := exec.Command("systemctl", "restart", serviceName).Run(); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not restart %s service (%v); restart it manually to pick up the new binary\n", serviceName, err)
}
return nil
}
type giteaRelease struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
} `json:"assets"`
}
func fetchLatestRelease(apiURL string) (*giteaRelease, error) {
c := &http.Client{Timeout: 30 * time.Second}
resp, err := c.Get(apiURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("releases API returned %s", resp.Status)
}
var r giteaRelease
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return nil, fmt.Errorf("decode release JSON: %w", err)
}
return &r, nil
}
// releaseAPIURL converts https://host/owner/repo (the same URL pasted into
// release_repo and shown in a browser) into the Gitea releases/latest API
// endpoint: https://host/api/v1/repos/owner/repo/releases/latest.
func releaseAPIURL(repoURL string) (string, error) {
u, err := url.Parse(repoURL)
if err != nil {
return "", fmt.Errorf("release_repo: %w", err)
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", fmt.Errorf("release_repo must look like https://host/owner/repo, got %q", repoURL)
}
return fmt.Sprintf("%s://%s/api/v1/repos/%s/%s/releases/latest", u.Scheme, u.Host, parts[0], parts[1]), nil
}
func download(srcURL, dst string) error {
c := &http.Client{Timeout: 5 * time.Minute}
resp, err := c.Get(srcURL)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download %s: %s", srcURL, resp.Status)
}
f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
if err != nil {
return err
}
defer f.Close()
if _, err := io.Copy(f, resp.Body); err != nil {
return err
}
return nil
}