From df8bf0d476630d9f1de9c62697b9c8260e4c758d Mon Sep 17 00:00:00 2001 From: Naeel Date: Wed, 29 Apr 2026 10:17:09 +0300 Subject: [PATCH] refactor: extract metrics-collector from fission-console, bump console to v1.3.12 --- console/deploy/console.yaml | 2 +- console/internal/api/cron_metrics.go | 203 ----------------- console/internal/api/server.go | 9 - metrics-collector/Dockerfile | 11 + metrics-collector/cmd/server/main.go | 40 ++++ .../deploy/metrics-collector.yaml | 56 +++++ metrics-collector/go.mod | 3 + metrics-collector/internal/api/server.go | 215 ++++++++++++++++++ .../cron.go => metrics-collector/ui/embed.go | 8 +- .../ui/index.html | 2 +- 10 files changed, 331 insertions(+), 218 deletions(-) delete mode 100644 console/internal/api/cron_metrics.go create mode 100644 metrics-collector/Dockerfile create mode 100644 metrics-collector/cmd/server/main.go create mode 100644 metrics-collector/deploy/metrics-collector.yaml create mode 100644 metrics-collector/go.mod create mode 100644 metrics-collector/internal/api/server.go rename console/ui/cron.go => metrics-collector/ui/embed.go (62%) rename console/ui/cron.html => metrics-collector/ui/index.html (99%) diff --git a/console/deploy/console.yaml b/console/deploy/console.yaml index 6c7f200..24a8b1e 100644 --- a/console/deploy/console.yaml +++ b/console/deploy/console.yaml @@ -52,7 +52,7 @@ spec: serviceAccountName: fission-console containers: - name: console - image: naeel/fission-console:v1.3.11 + image: naeel/fission-console:v1.3.12 ports: - containerPort: 8090 env: diff --git a/console/internal/api/cron_metrics.go b/console/internal/api/cron_metrics.go deleted file mode 100644 index 5869b9b..0000000 --- a/console/internal/api/cron_metrics.go +++ /dev/null @@ -1,203 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - "os" - "strconv" - "strings" - "sync" - "time" -) - -type CronMetric struct { - Timestamp string `json:"timestamp"` - Source string `json:"source"` - Hostname string `json:"hostname,omitempty"` - Status string `json:"status,omitempty"` - MemoryTotalMB float64 `json:"memory_total_mb,omitempty"` - MemoryFreeMB float64 `json:"memory_free_mb,omitempty"` - MemoryAvailMB float64 `json:"memory_available_mb,omitempty"` - MemoryUsedMB float64 `json:"memory_used_mb,omitempty"` - MemoryPercent float64 `json:"memory_percent,omitempty"` - CpuLoad1m float64 `json:"cpu_load_1m,omitempty"` - CpuLoad5m float64 `json:"cpu_load_5m,omitempty"` - CpuLoad15m float64 `json:"cpu_load_15m,omitempty"` - DiskTotalGB float64 `json:"disk_total_gb,omitempty"` - DiskFreeGB float64 `json:"disk_free_gb,omitempty"` - DiskUsedGB float64 `json:"disk_used_gb,omitempty"` - UptimeSec float64 `json:"uptime_sec,omitempty"` - Notes string `json:"notes,omitempty"` -} - -var ( - cronMetricMu sync.RWMutex - cronMetricHistory []CronMetric - cronMetricMaxKeep = 240 -) - -func (s *Server) handleCronMetrics(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - cronMetricMu.RLock() - history := append([]CronMetric(nil), cronMetricHistory...) - cronMetricMu.RUnlock() - latest := CronMetric{} - if len(history) > 0 { - latest = history[len(history)-1] - } - writeAnyJSON(w, http.StatusOK, map[string]any{ - "count": len(history), - "latest": latest, - "history": history, - }) - case http.MethodPost: - if !allowCronIngest(r) { - writeJSONError(w, http.StatusUnauthorized, "invalid cron token") - return - } - var payload map[string]any - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - writeJSONError(w, http.StatusBadRequest, "invalid json payload") - return - } - sample := normalizeCronMetric(payload) - cronMetricMu.Lock() - cronMetricHistory = append(cronMetricHistory, sample) - if len(cronMetricHistory) > cronMetricMaxKeep { - cronMetricHistory = append([]CronMetric(nil), cronMetricHistory[len(cronMetricHistory)-cronMetricMaxKeep:]...) - } - count := len(cronMetricHistory) - cronMetricMu.Unlock() - writeAnyJSON(w, http.StatusOK, map[string]any{"ok": true, "stored": count, "latest": sample}) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -func allowCronIngest(r *http.Request) bool { - expected := strings.TrimSpace(os.Getenv("CRON_TOKEN")) - if expected == "" { - return true - } - return strings.TrimSpace(r.Header.Get("X-Cron-Token")) == expected -} - -func normalizeCronMetric(payload map[string]any) CronMetric { - now := time.Now().UTC().Format(time.RFC3339) - metric := CronMetric{ - Timestamp: now, - Source: textValue(payload, "source", "function", "name", "host", "hostname", "instance"), - Hostname: textValue(payload, "hostname", "host", "node", "instance"), - Status: textValue(payload, "status"), - Notes: textValue(payload, "notes", "note", "message"), - } - if metric.Source == "" { - metric.Source = "cron" - } - - metric.MemoryTotalMB = numberValue(payload, "memory_total_mb", "total_memory_mb", "mem_total_mb", "mem_total") - metric.MemoryFreeMB = numberValue(payload, "memory_free_mb", "free_memory_mb", "mem_free_mb", "free_mb") - metric.MemoryAvailMB = numberValue(payload, "memory_available_mb", "available_memory_mb", "mem_available_mb", "avail_memory_mb") - metric.MemoryUsedMB = numberValue(payload, "memory_used_mb", "used_memory_mb", "mem_used_mb", "used_mb") - metric.MemoryPercent = numberValue(payload, "memory_percent", "mem_percent", "memory_usage_percent") - if metric.MemoryUsedMB == 0 && metric.MemoryTotalMB > 0 && metric.MemoryFreeMB > 0 { - metric.MemoryUsedMB = metric.MemoryTotalMB - metric.MemoryFreeMB - } - if metric.MemoryAvailMB == 0 && metric.MemoryFreeMB > 0 { - metric.MemoryAvailMB = metric.MemoryFreeMB - } - if metric.MemoryPercent == 0 && metric.MemoryTotalMB > 0 && metric.MemoryUsedMB > 0 { - metric.MemoryPercent = (metric.MemoryUsedMB / metric.MemoryTotalMB) * 100 - } - - metric.CpuLoad1m = numberValue(payload, "cpu_load_1m", "loadavg_1m", "load_1m", "load1") - metric.CpuLoad5m = numberValue(payload, "cpu_load_5m", "loadavg_5m", "load_5m", "load5") - metric.CpuLoad15m = numberValue(payload, "cpu_load_15m", "loadavg_15m", "load_15m", "load15") - - metric.DiskTotalGB = numberValue(payload, "disk_total_gb", "total_disk_gb", "disk_total_mb", "disk_total") - metric.DiskFreeGB = numberValue(payload, "disk_free_gb", "free_disk_gb", "disk_free_mb", "disk_free") - metric.DiskUsedGB = numberValue(payload, "disk_used_gb", "used_disk_gb", "disk_used_mb", "disk_used") - if metric.DiskUsedGB == 0 && metric.DiskTotalGB > 0 && metric.DiskFreeGB > 0 { - metric.DiskUsedGB = metric.DiskTotalGB - metric.DiskFreeGB - } - - if uptime := numberValue(payload, "uptime_sec", "uptime", "uptime_seconds"); uptime > 0 { - metric.UptimeSec = uptime - } - if ts := textValue(payload, "timestamp", "ts", "collected_at"); ts != "" { - metric.Timestamp = ts - } - return metric -} - -func textValue(payload map[string]any, keys ...string) string { - for _, key := range keys { - if value, ok := payload[key]; ok { - text := strings.TrimSpace(toString(value)) - if text != "" { - return text - } - } - } - return "" -} - -func numberValue(payload map[string]any, keys ...string) float64 { - for _, key := range keys { - if value, ok := payload[key]; ok { - if number, ok := toFloat64(value); ok { - return number - } - } - } - return 0 -} - -func toString(value any) string { - switch v := value.(type) { - case string: - return v - case []byte: - return string(v) - case float64: - return strconv.FormatFloat(v, 'f', -1, 64) - case float32: - return strconv.FormatFloat(float64(v), 'f', -1, 32) - case int: - return strconv.FormatInt(int64(v), 10) - case int64: - return strconv.FormatInt(v, 10) - case json.Number: - return v.String() - default: - return "" - } -} - -func toFloat64(value any) (float64, bool) { - switch v := value.(type) { - case float64: - return v, true - case float32: - return float64(v), true - case int: - return float64(v), true - case int64: - return float64(v), true - case json.Number: - f, err := v.Float64() - return f, err == nil - case string: - if strings.TrimSpace(v) == "" { - return 0, false - } - n, err := strconv.ParseFloat(strings.TrimSpace(v), 64) - if err == nil { - return n, true - } - return 0, false - default: - return 0, false - } -} diff --git a/console/internal/api/server.go b/console/internal/api/server.go index e9db27c..8da9770 100644 --- a/console/internal/api/server.go +++ b/console/internal/api/server.go @@ -124,12 +124,6 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { uiHandler := http.StripPrefix("/console", ui.Handler()) mux.Handle("/console", uiHandler) mux.Handle("/console/", uiHandler) - cronHandler := ui.CronHandler() - mux.Handle("/cron", cronHandler) - mux.Handle("/cron/", cronHandler) - mux.Handle("/console/cron", cronHandler) - mux.Handle("/console/cron/", cronHandler) - // Auth: не требует токена — сам проверяет и возвращает namespace mux.HandleFunc("/console/api/auth", s.handleAuth) @@ -144,9 +138,6 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/httptriggers", s.handleList(fission.HTTPTrigGVR)) mux.HandleFunc("/api/timetriggers", s.handleTimeTriggersRoot) mux.HandleFunc("/api/timetriggers/", s.handleTimeTriggersAction) - mux.HandleFunc("/cron/api/metrics", s.handleCronMetrics) - mux.HandleFunc("/console/cron/api/metrics", s.handleCronMetrics) - // Основные /console/api/* маршруты mux.HandleFunc("/console/api/environments", auth(s.handleList(fission.EnvironmentGVR))) mux.HandleFunc("/console/api/packages", auth(s.handleList(fission.PackageGVR))) diff --git a/metrics-collector/Dockerfile b/metrics-collector/Dockerfile new file mode 100644 index 0000000..a816820 --- /dev/null +++ b/metrics-collector/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.22-alpine AS builder +WORKDIR /build +COPY go.mod ./ +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o metrics-collector ./cmd/server/ + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates +COPY --from=builder /build/metrics-collector /metrics-collector +EXPOSE 8091 +ENTRYPOINT ["/metrics-collector"] diff --git a/metrics-collector/cmd/server/main.go b/metrics-collector/cmd/server/main.go new file mode 100644 index 0000000..62c4275 --- /dev/null +++ b/metrics-collector/cmd/server/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "log" + "net/http" + "os" + "time" + + "metrics-collector/internal/api" + "metrics-collector/ui" +) + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "8091" + } + + srv := api.NewServer() + + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte("ok\n")) + }) + mux.HandleFunc("/metrics", srv.HandleMetrics) + mux.Handle("/", ui.Handler()) + + httpSrv := &http.Server{ + Addr: ":" + port, + Handler: mux, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + } + + log.Printf("metrics-collector listening on :%s", port) + if err := httpSrv.ListenAndServe(); err != nil { + log.Fatalf("listen: %v", err) + } +} diff --git a/metrics-collector/deploy/metrics-collector.yaml b/metrics-collector/deploy/metrics-collector.yaml new file mode 100644 index 0000000..1c878cb --- /dev/null +++ b/metrics-collector/deploy/metrics-collector.yaml @@ -0,0 +1,56 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: metrics-collector + namespace: fission + labels: + app: metrics-collector +spec: + replicas: 1 + selector: + matchLabels: + app: metrics-collector + template: + metadata: + labels: + app: metrics-collector + spec: + containers: + - name: metrics-collector + image: naeel/metrics-collector:v0.1.0 + ports: + - containerPort: 8091 + env: + - name: PORT + value: "8091" + livenessProbe: + httpGet: + path: /health + port: 8091 + initialDelaySeconds: 5 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /health + port: 8091 + initialDelaySeconds: 3 + periodSeconds: 10 + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 100m + memory: 64Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: metrics-collector + namespace: fission +spec: + selector: + app: metrics-collector + ports: + - port: 8091 + targetPort: 8091 diff --git a/metrics-collector/go.mod b/metrics-collector/go.mod new file mode 100644 index 0000000..8ab857b --- /dev/null +++ b/metrics-collector/go.mod @@ -0,0 +1,3 @@ +module metrics-collector + +go 1.22 diff --git a/metrics-collector/internal/api/server.go b/metrics-collector/internal/api/server.go new file mode 100644 index 0000000..5868d12 --- /dev/null +++ b/metrics-collector/internal/api/server.go @@ -0,0 +1,215 @@ +package api + +import ( + "encoding/json" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" +) + +// Metric — один снимок состояния, принятый через POST /metrics. +type Metric struct { + Timestamp string `json:"timestamp"` + Source string `json:"source"` + Hostname string `json:"hostname,omitempty"` + Status string `json:"status,omitempty"` + MemoryTotalMB float64 `json:"memory_total_mb,omitempty"` + MemoryFreeMB float64 `json:"memory_free_mb,omitempty"` + MemoryAvailMB float64 `json:"memory_available_mb,omitempty"` + MemoryUsedMB float64 `json:"memory_used_mb,omitempty"` + MemoryPercent float64 `json:"memory_percent,omitempty"` + CpuLoad1m float64 `json:"cpu_load_1m,omitempty"` + CpuLoad5m float64 `json:"cpu_load_5m,omitempty"` + CpuLoad15m float64 `json:"cpu_load_15m,omitempty"` + DiskTotalGB float64 `json:"disk_total_gb,omitempty"` + DiskFreeGB float64 `json:"disk_free_gb,omitempty"` + DiskUsedGB float64 `json:"disk_used_gb,omitempty"` + UptimeSec float64 `json:"uptime_sec,omitempty"` + Notes string `json:"notes,omitempty"` +} + +const maxHistory = 240 + +// Server хранит историю снимков в памяти. +type Server struct { + mu sync.RWMutex + history []Metric +} + +func NewServer() *Server { + return &Server{} +} + +// HandleMetrics — единственный публичный эндпоинт. +// GET /metrics → отдаёт историю. +// POST /metrics → сохраняет снимок. +func (s *Server) HandleMetrics(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Metrics-Token") + + switch r.Method { + case http.MethodOptions: + w.WriteHeader(http.StatusNoContent) + + case http.MethodGet: + s.mu.RLock() + history := append([]Metric(nil), s.history...) + s.mu.RUnlock() + + latest := Metric{} + if len(history) > 0 { + latest = history[len(history)-1] + } + writeJSON(w, http.StatusOK, map[string]any{ + "count": len(history), + "latest": latest, + "history": history, + }) + + case http.MethodPost: + if !s.authorized(r) { + writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "invalid token"}) + return + } + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid json"}) + return + } + m := normalize(payload) + s.mu.Lock() + s.history = append(s.history, m) + if len(s.history) > maxHistory { + s.history = append([]Metric(nil), s.history[len(s.history)-maxHistory:]...) + } + count := len(s.history) + s.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "stored": count}) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// authorized проверяет METRICS_TOKEN если он задан. +func (s *Server) authorized(r *http.Request) bool { + expected := strings.TrimSpace(os.Getenv("METRICS_TOKEN")) + if expected == "" { + return true + } + return strings.TrimSpace(r.Header.Get("X-Metrics-Token")) == expected +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +// normalize приводит произвольный payload к структуре Metric. +func normalize(p map[string]any) Metric { + now := time.Now().UTC().Format(time.RFC3339) + m := Metric{ + Timestamp: now, + Source: textVal(p, "source", "function", "name", "host", "hostname", "instance"), + Hostname: textVal(p, "hostname", "host", "node", "instance"), + Status: textVal(p, "status"), + Notes: textVal(p, "notes", "note", "message"), + } + if m.Source == "" { + m.Source = "function" + } + + m.MemoryTotalMB = numVal(p, "memory_total_mb", "total_memory_mb", "mem_total_mb") + m.MemoryFreeMB = numVal(p, "memory_free_mb", "free_memory_mb", "mem_free_mb", "free_mb") + m.MemoryAvailMB = numVal(p, "memory_available_mb", "available_memory_mb", "mem_available_mb") + m.MemoryUsedMB = numVal(p, "memory_used_mb", "used_memory_mb", "mem_used_mb", "used_mb") + m.MemoryPercent = numVal(p, "memory_percent", "mem_percent", "memory_usage_percent") + + if m.MemoryUsedMB == 0 && m.MemoryTotalMB > 0 && m.MemoryFreeMB > 0 { + m.MemoryUsedMB = m.MemoryTotalMB - m.MemoryFreeMB + } + if m.MemoryAvailMB == 0 && m.MemoryFreeMB > 0 { + m.MemoryAvailMB = m.MemoryFreeMB + } + if m.MemoryPercent == 0 && m.MemoryTotalMB > 0 && m.MemoryUsedMB > 0 { + m.MemoryPercent = (m.MemoryUsedMB / m.MemoryTotalMB) * 100 + } + + m.CpuLoad1m = numVal(p, "cpu_load_1m", "loadavg_1m", "load_1m", "load1") + m.CpuLoad5m = numVal(p, "cpu_load_5m", "loadavg_5m", "load_5m", "load5") + m.CpuLoad15m = numVal(p, "cpu_load_15m", "loadavg_15m", "load_15m", "load15") + + m.DiskTotalGB = numVal(p, "disk_total_gb", "total_disk_gb") + m.DiskFreeGB = numVal(p, "disk_free_gb", "free_disk_gb") + m.DiskUsedGB = numVal(p, "disk_used_gb", "used_disk_gb") + if m.DiskUsedGB == 0 && m.DiskTotalGB > 0 && m.DiskFreeGB > 0 { + m.DiskUsedGB = m.DiskTotalGB - m.DiskFreeGB + } + + m.UptimeSec = numVal(p, "uptime_sec", "uptime", "uptime_seconds") + + if ts := textVal(p, "timestamp", "ts", "collected_at"); ts != "" { + m.Timestamp = ts + } + return m +} + +func textVal(p map[string]any, keys ...string) string { + for _, k := range keys { + if v, ok := p[k]; ok { + if s := strings.TrimSpace(anyToString(v)); s != "" { + return s + } + } + } + return "" +} + +func numVal(p map[string]any, keys ...string) float64 { + for _, k := range keys { + if v, ok := p[k]; ok { + if f, ok := anyToFloat64(v); ok { + return f + } + } + } + return 0 +} + +func anyToString(v any) string { + switch t := v.(type) { + case string: + return t + case float64: + return strconv.FormatFloat(t, 'f', -1, 64) + case json.Number: + return t.String() + default: + return "" + } +} + +func anyToFloat64(v any) (float64, bool) { + switch t := v.(type) { + case float64: + return t, true + case float32: + return float64(t), true + case int: + return float64(t), true + case int64: + return float64(t), true + case json.Number: + f, err := t.Float64() + return f, err == nil + case string: + f, err := strconv.ParseFloat(strings.TrimSpace(t), 64) + return f, err == nil + default: + return 0, false + } +} diff --git a/console/ui/cron.go b/metrics-collector/ui/embed.go similarity index 62% rename from console/ui/cron.go rename to metrics-collector/ui/embed.go index 3764e24..280e6f4 100644 --- a/console/ui/cron.go +++ b/metrics-collector/ui/embed.go @@ -5,12 +5,12 @@ import ( "net/http" ) -//go:embed cron.html -var cronPage string +//go:embed index.html +var page string -func CronHandler() http.Handler { +func Handler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write([]byte(cronPage)) + _, _ = w.Write([]byte(page)) }) } diff --git a/console/ui/cron.html b/metrics-collector/ui/index.html similarity index 99% rename from console/ui/cron.html rename to metrics-collector/ui/index.html index d7ac743..99aa15d 100644 --- a/console/ui/cron.html +++ b/metrics-collector/ui/index.html @@ -228,7 +228,7 @@