181 lines
5.6 KiB
Go
181 lines
5.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/example/ollama-fair-gateway/internal/hoststats"
|
|
)
|
|
|
|
type telemetry struct {
|
|
MemoryUsedBytes int64 `json:"memory_used_bytes,omitempty"`
|
|
MemoryTotalBytes int64 `json:"memory_total_bytes,omitempty"`
|
|
VRAMUsedBytes int64 `json:"vram_used_bytes,omitempty"`
|
|
VRAMTotalBytes int64 `json:"vram_total_bytes,omitempty"`
|
|
GPUUtilizationPct float64 `json:"gpu_utilization_percent,omitempty"`
|
|
GPUTemperatureC float64 `json:"gpu_temperature_c,omitempty"`
|
|
GPUPowerWatts float64 `json:"gpu_power_watts,omitempty"`
|
|
Source string `json:"source,omitempty"`
|
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type collector struct {
|
|
nvidia bool
|
|
nvidiaGPU string
|
|
amd bool
|
|
amdDevice string
|
|
}
|
|
|
|
func (c collector) collect(ctx context.Context) telemetry {
|
|
out := telemetry{UpdatedAt: time.Now().UTC()}
|
|
var errs []string
|
|
mctx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
|
|
m, err := hoststats.ReadMemory(mctx)
|
|
cancel()
|
|
if err != nil {
|
|
errs = append(errs, "memory: "+err.Error())
|
|
} else {
|
|
out.MemoryTotalBytes, out.MemoryUsedBytes = m.TotalBytes, m.UsedBytes
|
|
out.Source = appendSource(out.Source, "host-memory")
|
|
}
|
|
if c.nvidia {
|
|
gctx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
|
|
g, err := hoststats.ReadNVIDIA(gctx, c.nvidiaGPU)
|
|
cancel()
|
|
if err != nil {
|
|
errs = append(errs, "nvidia: "+err.Error())
|
|
} else {
|
|
out.VRAMUsedBytes, out.VRAMTotalBytes = g.MemoryUsedBytes, g.MemoryTotalBytes
|
|
out.GPUUtilizationPct, out.GPUTemperatureC, out.GPUPowerWatts = g.UtilizationPercent, g.TemperatureC, g.PowerWatts
|
|
out.Source = appendSource(out.Source, "nvidia-smi")
|
|
}
|
|
}
|
|
if c.amd {
|
|
gctx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
|
|
g, err := hoststats.ReadAMD(gctx, c.amdDevice)
|
|
cancel()
|
|
if err != nil {
|
|
errs = append(errs, "amd: "+err.Error())
|
|
} else {
|
|
out.VRAMUsedBytes, out.VRAMTotalBytes = g.MemoryUsedBytes, g.MemoryTotalBytes
|
|
out.GPUUtilizationPct, out.GPUTemperatureC, out.GPUPowerWatts = g.UtilizationPercent, g.TemperatureC, g.PowerWatts
|
|
out.Source = appendSource(out.Source, "amdgpu-sysfs")
|
|
}
|
|
}
|
|
out.Error = strings.Join(errs, "; ")
|
|
return out
|
|
}
|
|
|
|
func appendSource(cur, next string) string {
|
|
if cur == "" {
|
|
return next
|
|
}
|
|
return cur + "+" + next
|
|
}
|
|
|
|
type cidrAllowlist struct{ nets []*net.IPNet }
|
|
|
|
func parseCIDRs(raw string) (cidrAllowlist, error) {
|
|
var out cidrAllowlist
|
|
for _, part := range strings.Split(raw, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
_, n, err := net.ParseCIDR(part)
|
|
if err != nil {
|
|
return out, fmt.Errorf("invalid allow CIDR %q: %w", part, err)
|
|
}
|
|
out.nets = append(out.nets, n)
|
|
}
|
|
if len(out.nets) == 0 {
|
|
return out, fmt.Errorf("at least one allow CIDR is required")
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a cidrAllowlist) allowed(remote string) bool {
|
|
host, _, err := net.SplitHostPort(remote)
|
|
if err != nil {
|
|
host = remote
|
|
}
|
|
ip := net.ParseIP(strings.Trim(host, "[]"))
|
|
if ip == nil {
|
|
return false
|
|
}
|
|
for _, n := range a.nets {
|
|
if n.Contains(ip) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func main() {
|
|
listen := flag.String("listen", "127.0.0.1:11500", "listen address")
|
|
path := flag.String("path", "/telemetry", "telemetry path")
|
|
allow := flag.String("allow-cidrs", "127.0.0.1/32,::1/128", "comma-separated client CIDRs allowed to read telemetry")
|
|
nvidia := flag.Bool("nvidia-smi", false, "collect NVIDIA telemetry with nvidia-smi")
|
|
nvidiaGPU := flag.String("nvidia-gpu", "", "optional nvidia-smi GPU selector")
|
|
amd := flag.Bool("amd-sysfs", false, "collect Linux AMDGPU telemetry from sysfs")
|
|
amdDevice := flag.String("amd-device", "", "optional AMDGPU device path such as /sys/class/drm/card0/device; empty auto-detects")
|
|
once := flag.Bool("once", false, "print one telemetry sample as JSON and exit")
|
|
flag.Parse()
|
|
if !strings.HasPrefix(*path, "/") {
|
|
log.Fatal("-path must begin with /")
|
|
}
|
|
acl, err := parseCIDRs(*allow)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
c := collector{nvidia: *nvidia, nvidiaGPU: *nvidiaGPU, amd: *amd, amdDevice: *amdDevice}
|
|
if *once {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
if err := enc.Encode(c.collect(context.Background())); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
return
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
|
mux.HandleFunc(*path, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !acl.allowed(r.RemoteAddr) {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
_ = json.NewEncoder(w).Encode(c.collect(r.Context()))
|
|
})
|
|
srv := &http.Server{Addr: *listen, Handler: mux, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: time.Minute}
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
go func() {
|
|
<-ctx.Done()
|
|
shutdown, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
_ = srv.Shutdown(shutdown)
|
|
}()
|
|
log.Printf("worker telemetry listening on http://%s%s allowed=%s nvidia=%t amd=%t", *listen, *path, *allow, *nvidia, *amd)
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
log.Fatal(err)
|
|
}
|
|
}
|