Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eba478471f | |||
| dbce9aa56e | |||
| 60b9fbc42c | |||
| fff43a5ab6 | |||
| eeb85bbd8f |
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(go get *)",
|
||||
"Bash(go build *)",
|
||||
"Bash(go vet *)",
|
||||
"Read(//usr/lib/**)",
|
||||
"Read(//proc/**)",
|
||||
"Bash(systemctl show *)",
|
||||
"Bash(echo \"exit=$?\")",
|
||||
"Bash(systemctl list-units *)",
|
||||
"Bash(go test *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,8 @@ func main() {
|
||||
configFlag := fs.String("f", "", "config file path")
|
||||
fs.StringVar(configFlag, "config", "", "alias for -f")
|
||||
saveConfig := fs.Bool("save-config", false, "write default config and exit")
|
||||
showVersion := fs.Bool("v", false, "print version and exit")
|
||||
fs.BoolVar(showVersion, "version", false, "alias for -v")
|
||||
|
||||
rest := os.Args[1:]
|
||||
var args []string
|
||||
@@ -63,6 +65,11 @@ func main() {
|
||||
rest = rest[1:]
|
||||
}
|
||||
|
||||
if *showVersion {
|
||||
fmt.Println(nadir.Version)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if *configFlag != "" {
|
||||
os.Setenv("CONFIG_PATH", *configFlag)
|
||||
}
|
||||
@@ -102,6 +109,9 @@ func main() {
|
||||
case "token":
|
||||
ensureRoot()
|
||||
fatalIf(tokenCmd(args))
|
||||
case "update":
|
||||
ensureRoot()
|
||||
fatalIf(updateCmd(args))
|
||||
case "help", "-h", "--help":
|
||||
usage(os.Stdout)
|
||||
default:
|
||||
@@ -229,6 +239,8 @@ func runServer() {
|
||||
meta.Register(api, mods)
|
||||
meta.RegisterHealth(api, sessions)
|
||||
meta.RegisterWhoami(api, sessions, roles, mods)
|
||||
meta.ConfigPath = configPath
|
||||
meta.RegisterUpdate(api)
|
||||
|
||||
auth.RegisterLogin(api, sessions, auditStore, cfg.SecureCookie())
|
||||
auth.RegisterLogout(api, sessions, cfg.SecureCookie())
|
||||
|
||||
@@ -425,6 +425,8 @@ Usage:
|
||||
nadir token add <name> Mint a machine credential (Bearer token), shown once
|
||||
nadir token rm <name> Revoke a token (effective immediately, no restart)
|
||||
nadir token ls List token names and when they were created
|
||||
nadir update [--check|--force] Fetch the latest release from server.release_repo and restart
|
||||
(--check: report only; --force: re-download when already current)
|
||||
nadir help Show this help
|
||||
|
||||
Most commands need root. Config path is specified via -f/--config or CONFIG_PATH (default ~/.config/config.yaml).
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nadir"
|
||||
"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(args []string) error {
|
||||
fs := flag.NewFlagSet("update", flag.ExitOnError)
|
||||
check := fs.Bool("check", false, "report the latest version without downloading")
|
||||
force := fs.Bool("force", false, "re-download even when already at the latest version")
|
||||
fs.Parse(args)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fmt.Printf("current: %s\nlatest: %s\n", nadir.Version, rel.TagName)
|
||||
upToDate := nadir.Version == rel.TagName
|
||||
switch {
|
||||
case *check:
|
||||
if upToDate {
|
||||
fmt.Println("already up to date.")
|
||||
} else {
|
||||
fmt.Println("update available; run `nadir update` to install.")
|
||||
}
|
||||
return nil
|
||||
case upToDate && !*force:
|
||||
fmt.Println("already up to date; pass --force to re-download.")
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package meta
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
|
||||
"nadir/internal/config"
|
||||
"nadir/internal/oscmd"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// ConfigPath is set at startup so the update handler can re-load config and
|
||||
// surface release_repo / parse errors to the caller instead of only stderr.
|
||||
var ConfigPath string
|
||||
|
||||
// RegisterUpdate wires POST /api/meta/update. It runs the equivalent of
|
||||
// `sudo nadir update` in a detached session and returns 202 immediately; the
|
||||
// systemctl restart that ends the updater drops in-flight connections, so the
|
||||
// caller should poll /api/health to confirm the new version is up.
|
||||
//
|
||||
// Authorization: requires (meta, root). Only roles with a wildcard grant
|
||||
// (the default admin role) match, since "meta" isn't a real module with a
|
||||
// declared permission vocabulary.
|
||||
func RegisterUpdate(api huma.API) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "meta-update",
|
||||
Method: "POST",
|
||||
Path: "/api/update",
|
||||
Summary: "Update nadir to the latest release",
|
||||
Description: "Equivalent to running `sudo nadir update` on the host: queries server.release_repo for the latest release, downloads the binary matching the host's architecture, atomically replaces the running binary, and restarts the systemd unit. Returns 202 immediately; the service restart drops in-flight connections, so poll /api/health to confirm the new version is up. Requires the wildcard admin role.",
|
||||
Tags: []string{"Meta"},
|
||||
Metadata: map[string]any{"module": "meta", "permission": "root"},
|
||||
Errors: []int{400, 401, 403, 500},
|
||||
DefaultStatus: 202,
|
||||
}, func(ctx context.Context, _ *struct{}) (*oscmd.StatusOutput, error) {
|
||||
if ConfigPath != "" {
|
||||
cfg, err := config.Load(ConfigPath)
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("config load failed", err)
|
||||
}
|
||||
if cfg.Server.ReleaseRepo == "" {
|
||||
return nil, huma.Error400BadRequest("server.release_repo not set in " + ConfigPath)
|
||||
}
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, huma.Error500InternalServerError("could not resolve own binary path", err)
|
||||
}
|
||||
cmd := exec.Command(exe, "update")
|
||||
// Detach from the server's process group so `systemctl restart nadir`
|
||||
// (the final step of `nadir update`) doesn't kill its own updater.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, huma.Error500InternalServerError("could not start updater", err)
|
||||
}
|
||||
return oscmd.OK(), nil
|
||||
})
|
||||
}
|
||||
@@ -159,9 +159,18 @@ func cpuInfo() CPUInfo {
|
||||
c := CPUInfo{Model: cpuModel(string(data)), LogicalCPUs: runtime.NumCPU()}
|
||||
c.MinMHz, c.MaxMHz, c.CurrentMHz = cpuFreqMHz("/sys/devices/system/cpu")
|
||||
// ponytail: cpufreq sysfs is absent on many VMs and stock Ubuntu server
|
||||
// kernels; fall back to /proc/cpuinfo "cpu MHz" so CurrentMHz isn't 0.
|
||||
if c.CurrentMHz == 0 {
|
||||
c.CurrentMHz = cpuinfoMaxMHz(string(data))
|
||||
// kernels; fall back to /proc/cpuinfo "cpu MHz" — VMs have a fixed clock,
|
||||
// so min == max == cur is the honest answer.
|
||||
if mhz := cpuinfoMaxMHz(string(data)); mhz > 0 {
|
||||
if c.CurrentMHz == 0 {
|
||||
c.CurrentMHz = mhz
|
||||
}
|
||||
if c.MaxMHz == 0 {
|
||||
c.MaxMHz = mhz
|
||||
}
|
||||
if c.MinMHz == 0 {
|
||||
c.MinMHz = mhz
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user