This commit is contained in:
@@ -42,7 +42,8 @@ jobs:
|
||||
bin="hp-viz-${os}-${arch}"
|
||||
pkg="hp-viz-${os}-${arch}.tar.gz"
|
||||
GOOS=$os GOARCH=$arch CGO_ENABLED=0 go build \
|
||||
-ldflags="-s -w" -trimpath \
|
||||
-ldflags="-s -w -X github.com/honeypeer/cli-viz/internal/update.BuildCommit=${GITHUB_SHA}" \
|
||||
-trimpath \
|
||||
-o "dist/${bin}" ./cmd/hp-viz/
|
||||
tar -czf "dist/${pkg}" -C dist "${bin}"
|
||||
sha256sum "dist/${pkg}" | awk '{print $1}' > "dist/${pkg}.sha256"
|
||||
|
||||
@@ -84,7 +84,7 @@ Precedence: **flags → environment → config file → defaults**.
|
||||
| `Tab` | Feed → Incidents → Peers → Stats → Blog |
|
||||
| `Enter` | Open detail (or read blog post on Blog tab) |
|
||||
| `Esc` | Close detail / back from blog post / cancel filter or search |
|
||||
| `Ctrl+M` | Toggle mouse (off by default) |
|
||||
| `m` | Toggle mouse (off by default) |
|
||||
| `f` | Toggle follow mode (auto-scroll to newest) |
|
||||
| `s` | Search by attack or moderation ID (opens detail on match) |
|
||||
| `/` | Filter — text or `service:ssh geo:cn peer:id since:1h` |
|
||||
@@ -94,6 +94,19 @@ Precedence: **flags → environment → config file → defaults**.
|
||||
| `?` | Help overlay |
|
||||
| `q` | Quit |
|
||||
|
||||
## Updates
|
||||
|
||||
Release builds check for a newer rolling build on startup (every 6 hours by default). When a new commit is published to the [latest release](https://git.ssh.surf/snxraven/honeypeer-viz-cli/releases/tag/latest), `hp-viz` downloads the matching archive, verifies SHA256, and replaces itself in place.
|
||||
|
||||
```bash
|
||||
hp-viz --version # show embedded build commit
|
||||
hp-viz --update # check now and apply if newer
|
||||
hp-viz --no-update # skip automatic checks
|
||||
HP_VIZ_NO_UPDATE=1 hp-viz # same via environment
|
||||
```
|
||||
|
||||
Source builds (`go build` without release ldflags) are not auto-updated. Install via the curl installer to get a self-updating binary.
|
||||
|
||||
In the detail view, `j`/`k` or the mouse wheel scroll long content.
|
||||
|
||||
## What you see
|
||||
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
@@ -10,6 +12,7 @@ import (
|
||||
|
||||
"github.com/honeypeer/cli-viz/internal/config"
|
||||
"github.com/honeypeer/cli-viz/internal/ui"
|
||||
"github.com/honeypeer/cli-viz/internal/update"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -19,6 +22,21 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if cfg.Version {
|
||||
fmt.Println(update.VersionString())
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if cfg.Update {
|
||||
exitUpdate(true)
|
||||
}
|
||||
|
||||
if !cfg.NoUpdate && cfg.Fixture == "" {
|
||||
if msg := runAutoUpdate(); msg != "" {
|
||||
fmt.Fprintln(os.Stderr, msg)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.NoColor {
|
||||
os.Setenv("NO_COLOR", "1")
|
||||
lipgloss.SetColorProfile(termenv.Ascii)
|
||||
@@ -31,3 +49,47 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runAutoUpdate() string {
|
||||
opts := update.DefaultOptions()
|
||||
if base := strings.TrimSpace(os.Getenv("HP_VIZ_RELEASE_BASE")); base != "" {
|
||||
opts.ReleaseBase = strings.TrimRight(base, "/")
|
||||
}
|
||||
if h := config.EnvIntOr("HP_VIZ_UPDATE_INTERVAL_HOURS", 0); h > 0 {
|
||||
opts.Interval = time.Duration(h) * time.Hour
|
||||
}
|
||||
|
||||
res, err := update.MaybeCheck(opts)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("hp-viz update check failed: %v", err)
|
||||
}
|
||||
if res.Updated {
|
||||
return fmt.Sprintf("hp-viz updated to %s", res.CommitShort)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func exitUpdate(force bool) {
|
||||
opts := update.DefaultOptions()
|
||||
opts.Force = force
|
||||
if base := strings.TrimSpace(os.Getenv("HP_VIZ_RELEASE_BASE")); base != "" {
|
||||
opts.ReleaseBase = strings.TrimRight(base, "/")
|
||||
}
|
||||
|
||||
res, err := update.MaybeCheck(opts)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
switch {
|
||||
case res.Updated:
|
||||
fmt.Printf("Updated to %s\n", res.CommitShort)
|
||||
case res.Skipped == "already current":
|
||||
fmt.Printf("Already up to date (%s)\n", res.CommitShort)
|
||||
case res.Skipped == "source build":
|
||||
fmt.Fprintln(os.Stderr, "This is a source build (no embedded commit). Install from the latest release to enable updates.")
|
||||
os.Exit(1)
|
||||
default:
|
||||
fmt.Println(res.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ type Config struct {
|
||||
NoColor bool
|
||||
Bell bool
|
||||
Operator bool
|
||||
NoUpdate bool
|
||||
Version bool
|
||||
Update bool
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
@@ -32,16 +35,22 @@ func Load() (Config, error) {
|
||||
record := flag.String("record", "", "Append SSE events to JSONL file")
|
||||
noColor := flag.Bool("no-color", false, "Disable truecolor output")
|
||||
bell := flag.Bool("bell", false, "Terminal bell on block-tier events")
|
||||
noUpdate := flag.Bool("no-update", envBoolOr("HP_VIZ_NO_UPDATE", false), "Disable automatic update checks")
|
||||
showVersion := flag.Bool("version", false, "Print version and exit")
|
||||
forceUpdate := flag.Bool("update", false, "Check for updates, apply if available, and exit")
|
||||
flag.Parse()
|
||||
|
||||
cfg := Config{
|
||||
URL: trimSlash(*url),
|
||||
Secret: strings.TrimSpace(*secret),
|
||||
Buffer: *buffer,
|
||||
Fixture: *fixture,
|
||||
Record: *record,
|
||||
NoColor: *noColor,
|
||||
Bell: *bell,
|
||||
URL: trimSlash(*url),
|
||||
Secret: strings.TrimSpace(*secret),
|
||||
Buffer: *buffer,
|
||||
Fixture: *fixture,
|
||||
Record: *record,
|
||||
NoColor: *noColor,
|
||||
Bell: *bell,
|
||||
NoUpdate: *noUpdate,
|
||||
Version: *showVersion,
|
||||
Update: *forceUpdate,
|
||||
}
|
||||
cfg.Operator = cfg.Secret != ""
|
||||
|
||||
@@ -73,6 +82,23 @@ func envIntOr(key string, fallback int) int {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// EnvIntOr reads an integer environment variable.
|
||||
func EnvIntOr(key string, fallback int) int {
|
||||
return envIntOr(key, fallback)
|
||||
}
|
||||
|
||||
func envBoolOr(key string, fallback bool) bool {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func trimSlash(s string) string {
|
||||
for len(s) > 0 && s[len(s)-1] == '/' {
|
||||
s = s[:len(s)-1]
|
||||
|
||||
@@ -56,5 +56,7 @@ func setEnvIfEmpty(key, val string) {
|
||||
_ = os.Setenv("HP_VIZ_BELL", val)
|
||||
case "mouse":
|
||||
_ = os.Setenv("HP_VIZ_MOUSE", val)
|
||||
case "no_update":
|
||||
_ = os.Setenv("HP_VIZ_NO_UPDATE", val)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,7 +266,7 @@ func renderFooter(t Theme, filtering, searching bool, filter string, follow bool
|
||||
} else {
|
||||
hints = []string{"Tab view", "j/k nav", "Enter detail", "s search", "/ filter", "? help", "q quit"}
|
||||
}
|
||||
hints = append(hints, "ctrl+m mouse")
|
||||
hints = append(hints, "m mouse")
|
||||
if filtering {
|
||||
hints = []string{"type filter…", "Enter apply", "Esc cancel"}
|
||||
}
|
||||
@@ -304,7 +304,7 @@ func renderHelp(t Theme) string {
|
||||
PgUp Load more posts (list view)
|
||||
|
||||
Mouse (off by default)
|
||||
Ctrl+M Toggle mouse support
|
||||
m Toggle mouse support
|
||||
Click row Select row (when enabled)
|
||||
Wheel Scroll list (when enabled)
|
||||
Double-click Open detail (when enabled)
|
||||
|
||||
@@ -74,7 +74,7 @@ func defaultKeyMap() keyMap {
|
||||
Search: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "search")),
|
||||
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "detail")),
|
||||
Close: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close")),
|
||||
Mouse: key.NewBinding(key.WithKeys("ctrl+m"), key.WithHelp("ctrl+m", "mouse")),
|
||||
Mouse: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "mouse")),
|
||||
Reconnect: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reconnect")),
|
||||
PgUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "history")),
|
||||
PgDn: key.NewBinding(key.WithKeys("pgdn"), key.WithHelp("pgdn", "down")),
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultReleaseBase = "https://git.ssh.surf/snxraven/honeypeer-viz-cli/releases/download/latest"
|
||||
DefaultAPIURL = "https://git.ssh.surf/api/v1/repos/snxraven/honeypeer-viz-cli/releases/tags/latest"
|
||||
DefaultInterval = 6 * time.Hour
|
||||
stateFileName = "update-state.json"
|
||||
)
|
||||
|
||||
// BuildCommit is set at link time for release binaries (-ldflags -X ...BuildCommit=...).
|
||||
var BuildCommit string
|
||||
|
||||
type Options struct {
|
||||
ReleaseBase string
|
||||
APIURL string
|
||||
Client *http.Client
|
||||
StatePath string
|
||||
Interval time.Duration
|
||||
Force bool
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Updated bool
|
||||
Commit string
|
||||
CommitShort string
|
||||
Skipped string
|
||||
}
|
||||
|
||||
type releaseInfo struct {
|
||||
TargetCommitish string `json:"target_commitish"`
|
||||
}
|
||||
|
||||
type persistedState struct {
|
||||
LastCheck time.Time `json:"last_check"`
|
||||
LastCommit string `json:"last_commit"`
|
||||
}
|
||||
|
||||
func DefaultOptions() Options {
|
||||
statePath := ""
|
||||
if dir, err := os.UserCacheDir(); err == nil {
|
||||
statePath = filepath.Join(dir, "hp-viz", stateFileName)
|
||||
}
|
||||
return Options{
|
||||
ReleaseBase: DefaultReleaseBase,
|
||||
APIURL: DefaultAPIURL,
|
||||
Client: &http.Client{Timeout: 2 * time.Minute},
|
||||
StatePath: statePath,
|
||||
Interval: DefaultInterval,
|
||||
}
|
||||
}
|
||||
|
||||
func VersionString() string {
|
||||
commit := strings.TrimSpace(BuildCommit)
|
||||
if commit == "" {
|
||||
return "hp-viz dev (source build)"
|
||||
}
|
||||
return "hp-viz " + shortCommit(commit)
|
||||
}
|
||||
|
||||
func CurrentCommit() string {
|
||||
return normalizeCommit(BuildCommit)
|
||||
}
|
||||
|
||||
// MaybeCheck checks for a newer rolling release and applies it when available.
|
||||
func MaybeCheck(opts Options) (Result, error) {
|
||||
opts = normalizeOptions(opts)
|
||||
if !opts.Force && !isReleaseBuild() {
|
||||
return Result{Skipped: "source build"}, nil
|
||||
}
|
||||
if !opts.Force && shouldSkipByInterval(opts) {
|
||||
return Result{Skipped: "checked recently"}, nil
|
||||
}
|
||||
|
||||
remote, err := fetchLatestRelease(opts)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
remoteCommit := normalizeCommit(remote.TargetCommitish)
|
||||
localCommit := normalizeCommit(BuildCommit)
|
||||
|
||||
saveCheckTime(opts, remoteCommit)
|
||||
|
||||
if remoteCommit == "" {
|
||||
return Result{}, fmt.Errorf("release has no target commit")
|
||||
}
|
||||
if remoteCommit == localCommit && localCommit != "" {
|
||||
return Result{Skipped: "already current", Commit: remoteCommit, CommitShort: shortCommit(remoteCommit)}, nil
|
||||
}
|
||||
|
||||
bin, err := downloadBinary(opts)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
exe, err := selfPath()
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if err := applyBinary(exe, bin); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
saveCheckTime(opts, remoteCommit)
|
||||
return Result{
|
||||
Updated: true,
|
||||
Commit: remoteCommit,
|
||||
CommitShort: shortCommit(remoteCommit),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isReleaseBuild() bool {
|
||||
return normalizeCommit(BuildCommit) != ""
|
||||
}
|
||||
|
||||
func normalizeOptions(opts Options) Options {
|
||||
if opts.ReleaseBase == "" {
|
||||
opts.ReleaseBase = DefaultReleaseBase
|
||||
}
|
||||
if opts.APIURL == "" {
|
||||
opts.APIURL = DefaultAPIURL
|
||||
}
|
||||
if opts.Client == nil {
|
||||
opts.Client = &http.Client{Timeout: 2 * time.Minute}
|
||||
}
|
||||
if opts.Interval <= 0 {
|
||||
opts.Interval = DefaultInterval
|
||||
}
|
||||
if opts.StatePath == "" {
|
||||
if dir, err := os.UserCacheDir(); err == nil {
|
||||
opts.StatePath = filepath.Join(dir, "hp-viz", stateFileName)
|
||||
}
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func shouldSkipByInterval(opts Options) bool {
|
||||
if opts.StatePath == "" {
|
||||
return false
|
||||
}
|
||||
st, err := readState(opts.StatePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if st.LastCheck.IsZero() {
|
||||
return false
|
||||
}
|
||||
local := normalizeCommit(BuildCommit)
|
||||
if local != "" && st.LastCommit == local {
|
||||
return time.Since(st.LastCheck) < opts.Interval
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func saveCheckTime(opts Options, commit string) {
|
||||
if opts.StatePath == "" {
|
||||
return
|
||||
}
|
||||
st, _ := readState(opts.StatePath)
|
||||
st.LastCheck = time.Now()
|
||||
if commit != "" {
|
||||
st.LastCommit = commit
|
||||
}
|
||||
_ = writeState(opts.StatePath, st)
|
||||
}
|
||||
|
||||
func fetchLatestRelease(opts Options) (releaseInfo, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, opts.APIURL, nil)
|
||||
if err != nil {
|
||||
return releaseInfo{}, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", userAgent())
|
||||
|
||||
resp, err := opts.Client.Do(req)
|
||||
if err != nil {
|
||||
return releaseInfo{}, fmt.Errorf("check release: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return releaseInfo{}, fmt.Errorf("check release: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var info releaseInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return releaseInfo{}, fmt.Errorf("parse release: %w", err)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func platformArtifact() (pkg, bin string, err error) {
|
||||
osName := runtime.GOOS
|
||||
arch := runtime.GOARCH
|
||||
switch osName {
|
||||
case "linux", "darwin":
|
||||
default:
|
||||
return "", "", fmt.Errorf("auto-update unsupported on %s", osName)
|
||||
}
|
||||
switch arch {
|
||||
case "amd64", "arm64":
|
||||
default:
|
||||
return "", "", fmt.Errorf("auto-update unsupported on %s/%s", osName, arch)
|
||||
}
|
||||
pkg = fmt.Sprintf("hp-viz-%s-%s.tar.gz", osName, arch)
|
||||
bin = fmt.Sprintf("hp-viz-%s-%s", osName, arch)
|
||||
return pkg, bin, nil
|
||||
}
|
||||
|
||||
func downloadBinary(opts Options) ([]byte, error) {
|
||||
pkg, binName, err := platformArtifact()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base := strings.TrimRight(opts.ReleaseBase, "/")
|
||||
archiveURL := base + "/" + pkg
|
||||
sumURL := archiveURL + ".sha256"
|
||||
|
||||
archive, err := downloadURL(opts.Client, archiveURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download archive: %w", err)
|
||||
}
|
||||
sumBytes, err := downloadURL(opts.Client, sumURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download checksum: %w", err)
|
||||
}
|
||||
expected := strings.Fields(strings.TrimSpace(string(sumBytes)))[0]
|
||||
if err := verifySHA256(archive, expected); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return extractBinary(archive, binName)
|
||||
}
|
||||
|
||||
func downloadURL(client *http.Client, url string) ([]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent())
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 64<<20))
|
||||
}
|
||||
|
||||
func verifySHA256(data []byte, expected string) error {
|
||||
sum := sha256.Sum256(data)
|
||||
actual := hex.EncodeToString(sum[:])
|
||||
if !strings.EqualFold(actual, strings.TrimSpace(expected)) {
|
||||
return fmt.Errorf("checksum mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractBinary(archive []byte, binName string) ([]byte, error) {
|
||||
gr, err := gzip.NewReader(bytes.NewReader(archive))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer gr.Close()
|
||||
tr := tar.NewReader(gr)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hdr.Typeflag != tar.TypeReg {
|
||||
continue
|
||||
}
|
||||
if filepath.Base(hdr.Name) != binName {
|
||||
continue
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(tr, 64<<20))
|
||||
}
|
||||
return nil, fmt.Errorf("%s not found in archive", binName)
|
||||
}
|
||||
|
||||
func selfPath() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.EvalSymlinks(exe)
|
||||
}
|
||||
|
||||
func applyBinary(dest string, data []byte) error {
|
||||
dir := filepath.Dir(dest)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := probeWritable(dir); err != nil {
|
||||
return fmt.Errorf("cannot write to %s: %w (re-run install or use sudo)", dir, err)
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(dir, ".hp-viz-update-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Chmod(0o755); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpPath, dest); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func probeWritable(dir string) error {
|
||||
f, err := os.CreateTemp(dir, ".hp-viz-write-test-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := f.Name()
|
||||
f.Close()
|
||||
return os.Remove(name)
|
||||
}
|
||||
|
||||
func readState(path string) (persistedState, error) {
|
||||
var st persistedState
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
err = json.Unmarshal(data, &st)
|
||||
return st, err
|
||||
}
|
||||
|
||||
func writeState(path string, st persistedState) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
}
|
||||
|
||||
func normalizeCommit(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func shortCommit(s string) string {
|
||||
s = normalizeCommit(s)
|
||||
if len(s) <= 7 {
|
||||
return s
|
||||
}
|
||||
return s[:7]
|
||||
}
|
||||
|
||||
func userAgent() string {
|
||||
c := shortCommit(BuildCommit)
|
||||
if c == "" {
|
||||
return "hp-viz/dev"
|
||||
}
|
||||
return "hp-viz/" + c
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMaybeCheckAlreadyCurrent(t *testing.T) {
|
||||
BuildCommit = "abc1234567890deadbeef"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(releaseInfo{TargetCommitish: BuildCommit})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
opts := DefaultOptions()
|
||||
opts.APIURL = srv.URL
|
||||
opts.Force = true
|
||||
|
||||
res, err := MaybeCheck(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Updated {
|
||||
t.Fatal("expected no update")
|
||||
}
|
||||
if res.Skipped != "already current" {
|
||||
t.Fatalf("skipped = %q", res.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeCheckSkipsSourceBuild(t *testing.T) {
|
||||
BuildCommit = ""
|
||||
res, err := MaybeCheck(DefaultOptions())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Skipped != "source build" {
|
||||
t.Fatalf("skipped = %q", res.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadAndExtractBinary(t *testing.T) {
|
||||
binName := "hp-viz-linux-amd64"
|
||||
payload := []byte("#!/bin/sh\necho hi\n")
|
||||
archive := buildTestArchive(t, binName, payload)
|
||||
sum := sha256.Sum256(archive)
|
||||
sumHex := hex.EncodeToString(sum[:])
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+binName+".tar.gz", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(archive)
|
||||
})
|
||||
mux.HandleFunc("/"+binName+".tar.gz.sha256", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(sumHex + "\n"))
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
opts := DefaultOptions()
|
||||
opts.ReleaseBase = srv.URL
|
||||
got, err := downloadBinaryWithPlatform(opts, "linux", "amd64")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(payload) {
|
||||
t.Fatalf("binary mismatch: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyBinary(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dest := filepath.Join(dir, "hp-viz")
|
||||
payload := []byte{0x7f, 'E', 'L', 'F'}
|
||||
if err := applyBinary(dest, payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(dest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(payload) {
|
||||
t.Fatal("written binary mismatch")
|
||||
}
|
||||
info, err := os.Stat(dest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm()&0o111 == 0 {
|
||||
t.Fatal("binary not executable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkipByInterval(t *testing.T) {
|
||||
BuildCommit = "abc1234"
|
||||
dir := t.TempDir()
|
||||
statePath := filepath.Join(dir, "state.json")
|
||||
_ = writeState(statePath, persistedState{
|
||||
LastCheck: time.Now(),
|
||||
LastCommit: "abc1234",
|
||||
})
|
||||
opts := DefaultOptions()
|
||||
opts.StatePath = statePath
|
||||
opts.Interval = time.Hour
|
||||
if !shouldSkipByInterval(opts) {
|
||||
t.Fatal("expected skip within interval")
|
||||
}
|
||||
}
|
||||
|
||||
func buildTestArchive(t *testing.T, name string, payload []byte) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
gw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gw)
|
||||
if err := tw.WriteHeader(&tar.Header{
|
||||
Name: name,
|
||||
Mode: 0o755,
|
||||
Size: int64(len(payload)),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write(payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func downloadBinaryWithPlatform(opts Options, goos, goarch string) ([]byte, error) {
|
||||
pkg := fmt.Sprintf("hp-viz-%s-%s.tar.gz", goos, goarch)
|
||||
binName := fmt.Sprintf("hp-viz-%s-%s", goos, goarch)
|
||||
base := strings.TrimRight(opts.ReleaseBase, "/")
|
||||
archiveURL := base + "/" + pkg
|
||||
sumURL := archiveURL + ".sha256"
|
||||
|
||||
archive, err := downloadURL(opts.Client, archiveURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sumBytes, err := downloadURL(opts.Client, sumURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expected := strings.Fields(strings.TrimSpace(string(sumBytes)))[0]
|
||||
if err := verifySHA256(archive, expected); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return extractBinary(archive, binName)
|
||||
}
|
||||
Reference in New Issue
Block a user