@@ -0,0 +1,382 @@
package stats
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync"
"time"
)
// GrafanaProvider реализует StatsProvider через Grafana HTTP API.
//
// Для каждого namespace создаётся изолированная Grafana Organization:
// - PostgreSQL datasource (тот же DSN, uid="fission-user-pg")
// - Dashboard с hardcoded WHERE namespace='...'
// - Public Dashboard (без логина) → accessToken
//
// Потокобезопасен: sync.RWMutex + per-namespace singleflight.
type GrafanaProvider struct {
internalURL string // http://grafana.grafana.svc.cluster.local:3000
publicURL string // https://fission.kube5s.ru/grafana
adminUser string
adminPass string
http * http . Client
mu sync . RWMutex
tokens map [ string ] string // namespace → publicDashboardAccessToken
orgIDs map [ string ] int64 // namespace → grafana orgId
}
// NewGrafanaProvider создаёт GrafanaProvider.
func NewGrafanaProvider ( internalURL , publicURL , adminUser , adminPass string ) * GrafanaProvider {
return & GrafanaProvider {
internalURL : strings . TrimRight ( internalURL , "/" ) ,
publicURL : strings . TrimRight ( publicURL , "/" ) ,
adminUser : adminUser ,
adminPass : adminPass ,
http : & http . Client { Timeout : 20 * time . Second } ,
tokens : make ( map [ string ] string ) ,
orgIDs : make ( map [ string ] int64 ) ,
}
}
// EnsureOrgForNamespace идемпотентно создаёт Grafana Org + datasource + dashboard + public link.
func ( g * GrafanaProvider ) EnsureOrgForNamespace ( ctx context . Context , namespace , email string ) error {
// Быстрый путь: уже провизировано в этом процессе
g . mu . RLock ( )
_ , cached := g . tokens [ namespace ]
g . mu . RUnlock ( )
if cached {
return nil
}
// Шаг 1: получить или создать Org
orgID , err := g . getOrCreateOrg ( ctx , namespace )
if err != nil {
return fmt . Errorf ( "getOrCreateOrg(%s): %w" , namespace , err )
}
// Шаг 2: создать datasource в этой Org (идемпотентно)
if err := g . ensureDatasource ( ctx , orgID ) ; err != nil {
log . Printf ( "stats: ensureDatasource org=%d ns=%s: %v" , orgID , namespace , err )
// не фатально — dashboard может не работать но org создана
}
// Шаг 3: создать dashboard с hardcoded namespace (идемпотентно)
dashUID , err := g . ensureDashboard ( ctx , orgID , namespace )
if err != nil {
return fmt . Errorf ( "ensureDashboard org=%d ns=%s: %w" , orgID , namespace , err )
}
// Шаг 4: получить или создать public dashboard → accessToken
token , err := g . ensurePublicDashboard ( ctx , orgID , dashUID )
if err != nil {
return fmt . Errorf ( "ensurePublicDashboard org=%d dash=%s: %w" , orgID , dashUID , err )
}
// Кэшируем
g . mu . Lock ( )
g . tokens [ namespace ] = token
g . orgIDs [ namespace ] = orgID
g . mu . Unlock ( )
log . Printf ( "stats: org provisioned ns=%s orgId=%d publicToken=%s..." , namespace , orgID , token [ : 8 ] )
return nil
}
// DashboardURL возвращает публичный URL или "" если ещё не провизировано.
func ( g * GrafanaProvider ) DashboardURL ( ctx context . Context , namespace string ) string {
// Сначала пробуем из кэша
g . mu . RLock ( )
token , ok := g . tokens [ namespace ]
g . mu . RUnlock ( )
if ok && token != "" {
return g . publicURL + "/public-dashboards/" + token
}
// Кэш промах (после перезапуска сервера) — провизируем заново
if err := g . EnsureOrgForNamespace ( ctx , namespace , "" ) ; err != nil {
log . Printf ( "stats: DashboardURL re-provision ns=%s: %v" , namespace , err )
return ""
}
g . mu . RLock ( )
token = g . tokens [ namespace ]
g . mu . RUnlock ( )
if token == "" {
return ""
}
return g . publicURL + "/public-dashboards/" + token
}
// --- Grafana API helpers ---
// getOrCreateOrg возвращает orgId существующей или создаёт новую Org.
func ( g * GrafanaProvider ) getOrCreateOrg ( ctx context . Context , namespace string ) ( int64 , error ) {
// Проверяем кэш orgIDs
g . mu . RLock ( )
if id , ok := g . orgIDs [ namespace ] ; ok {
g . mu . RUnlock ( )
return id , nil
}
g . mu . RUnlock ( )
// GET /api/orgs/name/{namespace}
resp , body , err := g . grafanaRequest ( ctx , http . MethodGet , "/api/orgs/name/" + namespace , 0 , nil )
if err != nil {
return 0 , err
}
if resp . StatusCode == http . StatusOK {
var org struct {
ID int64 ` json:"id" `
}
if err := json . Unmarshal ( body , & org ) ; err != nil {
return 0 , fmt . Errorf ( "parse org: %w" , err )
}
return org . ID , nil
}
// Org не найдена — создаём
payload := map [ string ] string { "name" : namespace }
resp , body , err = g . grafanaRequest ( ctx , http . MethodPost , "/api/orgs" , 0 , payload )
if err != nil {
return 0 , err
}
if resp . StatusCode != http . StatusOK && resp . StatusCode != http . StatusCreated {
return 0 , fmt . Errorf ( "create org status=%d body=%s" , resp . StatusCode , string ( body ) )
}
var created struct {
OrgID int64 ` json:"orgId" `
}
if err := json . Unmarshal ( body , & created ) ; err != nil {
return 0 , fmt . Errorf ( "parse create org: %w" , err )
}
return created . OrgID , nil
}
// ensureDatasource создаёт PostgreSQL datasource в org (uid="fission-user-pg").
// Идемпотентен: 409 Conflict считается успехом.
func ( g * GrafanaProvider ) ensureDatasource ( ctx context . Context , orgID int64 ) error {
// Проверяем есть ли уже datasource в этой org
resp , _ , err := g . grafanaRequest ( ctx , http . MethodGet , "/api/datasources/uid/fission-user-pg" , orgID , nil )
if err != nil {
return err
}
if resp . StatusCode == http . StatusOK {
return nil // уже есть
}
// Получаем DSN из уже существующего datasource в Org 1 (uid=fission-pg)
_ , body , err := g . grafanaRequest ( ctx , http . MethodGet , "/api/datasources/uid/fission-pg" , 1 , nil )
if err != nil {
return fmt . Errorf ( "get main datasource: %w" , err )
}
var ds struct {
URL string ` json:"url" `
JSONData json . RawMessage ` json:"jsonData" `
SecureJSONData struct {
Password string ` json:"password" `
} ` json:"secureJsonData" `
}
if err := json . Unmarshal ( body , & ds ) ; err != nil {
return fmt . Errorf ( "parse main datasource: %w" , err )
}
// Создаём копию datasource в новой Org
payload := map [ string ] any {
"name" : "fission-pg" ,
"type" : "postgres" ,
"uid" : "fission-user-pg" ,
"url" : ds . URL ,
"access" : "proxy" ,
"jsonData" : map [ string ] any {
"sslmode" : "disable" ,
"postgresVersion" : 1700 ,
"timescaledb" : false ,
} ,
"secureJsonData" : ds . SecureJSONData ,
}
resp , body , err = g . grafanaRequest ( ctx , http . MethodPost , "/api/datasources" , orgID , payload )
if err != nil {
return err
}
if resp . StatusCode == http . StatusConflict {
return nil // уже существует
}
if resp . StatusCode != http . StatusOK && resp . StatusCode != http . StatusCreated {
return fmt . Errorf ( "create datasource status=%d body=%s" , resp . StatusCode , string ( body ) )
}
return nil
}
// ensureDashboard создаёт/обновляет user-дашборд в org.
// Возвращает uid дашборда.
func ( g * GrafanaProvider ) ensureDashboard ( ctx context . Context , orgID int64 , namespace string ) ( string , error ) {
const dashUID = "fission-user-overview"
// Проверяем существование
resp , _ , err := g . grafanaRequest ( ctx , http . MethodGet , "/api/dashboards/uid/" + dashUID , orgID , nil )
if err != nil {
return "" , err
}
if resp . StatusCode == http . StatusOK {
return dashUID , nil // уже есть
}
// Импортируем dashboard JSON с hardcoded namespace
dashJSON := userDashboardJSON ( namespace )
payload := map [ string ] any {
"dashboard" : json . RawMessage ( dashJSON ) ,
"overwrite" : true ,
"folderId" : 0 ,
}
resp , body , err := g . grafanaRequest ( ctx , http . MethodPost , "/api/dashboards/db" , orgID , payload )
if err != nil {
return "" , err
}
if resp . StatusCode != http . StatusOK {
return "" , fmt . Errorf ( "import dashboard status=%d body=%s" , resp . StatusCode , string ( body ) )
}
return dashUID , nil
}
// ensurePublicDashboard создаёт public dashboard и возвращает accessToken.
// Идемпотентен: если уже существует — возвращает существующий token.
func ( g * GrafanaProvider ) ensurePublicDashboard ( ctx context . Context , orgID int64 , dashUID string ) ( string , error ) {
path := "/api/dashboards/uid/" + dashUID + "/public-dashboards"
// Проверяем существование
resp , body , err := g . grafanaRequest ( ctx , http . MethodGet , path , orgID , nil )
if err != nil {
return "" , err
}
if resp . StatusCode == http . StatusOK {
var pd struct {
AccessToken string ` json:"accessToken" `
}
if err := json . Unmarshal ( body , & pd ) ; err != nil {
return "" , fmt . Errorf ( "parse public dashboard: %w" , err )
}
if pd . AccessToken != "" {
return pd . AccessToken , nil
}
}
// Создаём
payload := map [ string ] any {
"isEnabled" : true ,
"annotationsEnabled" : false ,
"timeSelectionEnabled" : true ,
}
resp , body , err = g . grafanaRequest ( ctx , http . MethodPost , path , orgID , payload )
if err != nil {
return "" , err
}
if resp . StatusCode != http . StatusOK && resp . StatusCode != http . StatusCreated {
return "" , fmt . Errorf ( "create public dashboard status=%d body=%s" , resp . StatusCode , string ( body ) )
}
var pd struct {
AccessToken string ` json:"accessToken" `
}
if err := json . Unmarshal ( body , & pd ) ; err != nil {
return "" , fmt . Errorf ( "parse created public dashboard: %w" , err )
}
if pd . AccessToken == "" {
return "" , fmt . Errorf ( "empty accessToken in response: %s" , string ( body ) )
}
return pd . AccessToken , nil
}
// grafanaRequest выполняет HTTP запрос к Grafana API.
// orgID > 0 → устанавливает X-Grafana-Org-Id заголовок (thread-safe, без смены контекста).
// orgID == 0 → без заголовка (используется Org 1 admin по умолчанию).
func ( g * GrafanaProvider ) grafanaRequest ( ctx context . Context , method , path string , orgID int64 , payload any ) ( * http . Response , [ ] byte , error ) {
var bodyReader io . Reader
if payload != nil {
data , err := json . Marshal ( payload )
if err != nil {
return nil , nil , fmt . Errorf ( "marshal payload: %w" , err )
}
bodyReader = bytes . NewReader ( data )
}
req , err := http . NewRequestWithContext ( ctx , method , g . internalURL + path , bodyReader )
if err != nil {
return nil , nil , fmt . Errorf ( "new request: %w" , err )
}
req . SetBasicAuth ( g . adminUser , g . adminPass )
if payload != nil {
req . Header . Set ( "Content-Type" , "application/json" )
}
if orgID > 0 {
req . Header . Set ( "X-Grafana-Org-Id" , fmt . Sprintf ( "%d" , orgID ) )
}
resp , err := g . http . Do ( req )
if err != nil {
return nil , nil , fmt . Errorf ( "do request %s %s: %w" , method , path , err )
}
defer resp . Body . Close ( )
body , err := io . ReadAll ( resp . Body )
if err != nil {
return resp , nil , fmt . Errorf ( "read body: %w" , err )
}
return resp , body , nil
}
// userDashboardJSON генерирует JSON дашборда для конкретного namespace.
// Namespace вшит прямо в SQL запросы — без template variables.
// Dashboard uid="fission-user-overview" (per-org, без конфликтов между org).
func userDashboardJSON ( namespace string ) string {
// Безопасное экранирование namespace для SQL (namespace это sha256 hex — только [a-z0-9-])
ns := strings . ReplaceAll ( namespace , "'" , "''" )
return fmt . Sprintf ( ` {
"title": "Мои функции — %s",
"uid": "fission-user-overview",
"tags": ["fission", "user"],
"timezone": "browser",
"refresh": "1m",
"time": { "from": "now-24h", "to": "now"},
"panels": [
{
"id": 1, "title": "Вызовы в час", "type": "timeseries",
"gridPos": { "x": 0, "y": 0, "w": 16, "h": 8},
"datasource": { "type": "postgres", "uid": "fission-user-pg"},
"targets": [ { "rawSql": "SELECT date_trunc('hour', started_at) AS time, count(*) AS value, function_name FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() GROUP BY 1, function_name ORDER BY 1", "format": "time_series", "refId": "A"}]
},
{
"id": 2, "title": "Успех vs Ошибки", "type": "piechart",
"gridPos": { "x": 16, "y": 0, "w": 8, "h": 8},
"datasource": { "type": "postgres", "uid": "fission-user-pg"},
"targets": [ { "rawSql": "SELECT CASE WHEN status_code >= 200 AND status_code < 300 THEN 'success' WHEN status_code = 0 THEN 'event' ELSE 'error' END AS metric, count(*) AS value FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() GROUP BY 1", "format": "table", "refId": "A"}]
},
{
"id": 3, "title": "Топ функций", "type": "bargauge",
"gridPos": { "x": 0, "y": 8, "w": 12, "h": 8},
"datasource": { "type": "postgres", "uid": "fission-user-pg"},
"targets": [ { "rawSql": "SELECT function_name AS metric, count(*) AS value FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() GROUP BY 1 ORDER BY 2 DESC LIMIT 10", "format": "table", "refId": "A"}]
},
{
"id": 4, "title": "Средняя латентность (ms)", "type": "timeseries",
"gridPos": { "x": 12, "y": 8, "w": 12, "h": 8},
"datasource": { "type": "postgres", "uid": "fission-user-pg"},
"targets": [ { "rawSql": "SELECT date_trunc('hour', started_at) AS time, round(avg(duration_ms)) AS avg_ms FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() AND trigger_type != 'event' GROUP BY 1 ORDER BY 1", "format": "time_series", "refId": "A"}]
},
{
"id": 5, "title": "Последние события", "type": "table",
"gridPos": { "x": 0, "y": 16, "w": 24, "h": 8},
"datasource": { "type": "postgres", "uid": "fission-user-pg"},
"targets": [ { "rawSql": "SELECT started_at AS time, function_name, trigger_type, event_type, status_code, duration_ms, error_msg FROM invocations WHERE namespace = '%s' ORDER BY started_at DESC LIMIT 50", "format": "table", "refId": "A"}]
}
],
"schemaVersion": 39
} ` , namespace , ns , ns , ns , ns , ns )
}