diff --git a/console/cmd/server/main.go b/console/cmd/server/main.go index 7afae4a..7579d33 100644 --- a/console/cmd/server/main.go +++ b/console/cmd/server/main.go @@ -12,6 +12,7 @@ import ( "fission-console/internal/api" "fission-console/internal/auth" + "fission-console/internal/billing" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" @@ -48,6 +49,8 @@ func main() { Demo: &auth.DemoAuthenticator{}, } + billingStore := billing.NewStore() + srv := api.NewServer(api.Config{ Dyn: dyn, Kube: kube, @@ -64,6 +67,7 @@ func main() { LLMUrl: envDefault("FISSION_LLM_URL", "https://api.aillm.ru"), LLMKey: os.Getenv("FISSION_LLM_KEY"), // --- end ai/ask feature --- + Billing: billingStore, }) // Запускаем фоновые горутины: reaper истёкших функций diff --git a/console/deploy/console.yaml b/console/deploy/console.yaml index 7894314..ff59969 100644 --- a/console/deploy/console.yaml +++ b/console/deploy/console.yaml @@ -55,7 +55,7 @@ spec: serviceAccountName: fission-console containers: - name: console - image: naeel/fission-console:v1.3.85 + image: naeel/fission-console:v1.3.86 imagePullPolicy: Always ports: - containerPort: 8090 @@ -82,6 +82,8 @@ spec: key: password - name: FISSION_STORAGESVC_URL value: "http://storagesvc.fission.svc.cluster.local" + - name: BILLING_DSN + value: "postgres://super:BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif@postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432/sqsdb" livenessProbe: httpGet: path: /health diff --git a/console/go.mod b/console/go.mod index 842e855..0ed6ced 100644 --- a/console/go.mod +++ b/console/go.mod @@ -20,6 +20,10 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.9.2 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect @@ -32,6 +36,7 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect diff --git a/console/go.sum b/console/go.sum index 778ac55..7a99a78 100644 --- a/console/go.sum +++ b/console/go.sum @@ -29,6 +29,14 @@ github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgY github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -70,6 +78,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -99,6 +108,8 @@ golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKl golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/console/internal/api/function_clone.go b/console/internal/api/function_clone.go index beed743..4069a26 100644 --- a/console/internal/api/function_clone.go +++ b/console/internal/api/function_clone.go @@ -19,6 +19,7 @@ import ( "strings" "time" + "fission-console/internal/billing" "fission-console/internal/fission" "fission-console/internal/runtime" @@ -241,6 +242,16 @@ func (s *Server) handleCloneFunction(w http.ResponseWriter, r *http.Request, src return } + s.billing.RecordInvocation(billing.Invocation{ + Namespace: ns, + FunctionName: req.NewName, + TriggerType: billing.TriggerEvent, + StartedAt: now, + StatusCode: http.StatusCreated, + RecordedBy: "console", + EventType: "clone", + }) + writeAnyJSON(w, http.StatusCreated, map[string]any{ "name": req.NewName, "cloned_from": srcName, diff --git a/console/internal/api/function_code.go b/console/internal/api/function_code.go index c622c8c..91fa34a 100644 --- a/console/internal/api/function_code.go +++ b/console/internal/api/function_code.go @@ -22,6 +22,7 @@ import ( "strings" "time" + "fission-console/internal/billing" "fission-console/internal/fission" "fission-console/internal/model" "fission-console/internal/runtime" @@ -275,6 +276,16 @@ func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) { return } + s.billing.RecordInvocation(billing.Invocation{ + Namespace: ns, + FunctionName: req.Name, + TriggerType: billing.TriggerEvent, + StartedAt: now, + StatusCode: http.StatusCreated, + RecordedBy: "console", + EventType: "create", + }) + writeAnyJSON(w, http.StatusCreated, map[string]any{ "name": req.Name, "package": pkgName, diff --git a/console/internal/api/function_crud.go b/console/internal/api/function_crud.go index 1a094fe..8e316b7 100644 --- a/console/internal/api/function_crud.go +++ b/console/internal/api/function_crud.go @@ -21,6 +21,7 @@ import ( "strings" "time" + "fission-console/internal/billing" "fission-console/internal/fission" corev1 "k8s.io/api/core/v1" @@ -228,6 +229,16 @@ func (s *Server) handleDeleteFunction(w http.ResponseWriter, r *http.Request, na // (reconciler NS удалён — за FISSION_RESOURCE_NAMESPACES теперь отвечает Layer 1 NSWatcher) + s.billing.RecordInvocation(billing.Invocation{ + Namespace: ns, + FunctionName: name, + TriggerType: billing.TriggerEvent, + StartedAt: time.Now(), + StatusCode: http.StatusOK, + RecordedBy: "console", + EventType: "delete", + }) + writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name, "package": pkgName}) } diff --git a/console/internal/api/function_invoke.go b/console/internal/api/function_invoke.go index 224da47..51b0335 100644 --- a/console/internal/api/function_invoke.go +++ b/console/internal/api/function_invoke.go @@ -22,6 +22,7 @@ import ( "strings" "time" + "fission-console/internal/billing" "fission-console/internal/fission" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -202,9 +203,26 @@ func (s *Server) handleInvokeFunction(w http.ResponseWriter, r *http.Request, na defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) + + durationMS := time.Since(start).Milliseconds() + s.billing.RecordInvocation(billing.Invocation{ + Namespace: ns, + FunctionName: name, + TriggerType: billing.TriggerConsole, + Route: invokeURL, + HTTPMethod: invokeMethod, + StartedAt: start, + DurationMS: durationMS, + StatusCode: resp.StatusCode, + RequestBytes: int64(len(bodyBytes)), + ResponseBytes: int64(len(respBody)), + RecordedBy: "console", + EventType: "invoke", + }) + writeAnyJSON(w, http.StatusOK, map[string]any{ "status": resp.StatusCode, - "latency_ms": time.Since(start).Milliseconds(), + "latency_ms": durationMS, "response_raw": string(respBody), }) } @@ -311,9 +329,26 @@ func (s *Server) invokeInternalFunction(w http.ResponseWriter, r *http.Request, defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) + + durationMS := time.Since(start).Milliseconds() + s.billing.RecordInvocation(billing.Invocation{ + Namespace: namespace, + FunctionName: functionName, + TriggerType: billing.TriggerHTTP, + Route: extraPath, + HTTPMethod: r.Method, + StartedAt: start, + DurationMS: durationMS, + StatusCode: resp.StatusCode, + RequestBytes: int64(len(bodyBytes)), + ResponseBytes: int64(len(respBody)), + RecordedBy: "console", + EventType: "invoke", + }) + writeAnyJSON(w, http.StatusOK, map[string]any{ "status": resp.StatusCode, - "latency_ms": time.Since(start).Milliseconds(), + "latency_ms": durationMS, "response_raw": string(respBody), }) } diff --git a/console/internal/api/server.go b/console/internal/api/server.go index 1f69533..77eed0f 100644 --- a/console/internal/api/server.go +++ b/console/internal/api/server.go @@ -13,6 +13,7 @@ import ( "time" "fission-console/internal/auth" + "fission-console/internal/billing" "fission-console/internal/cloud" "fission-console/internal/fission" "fission-console/ui" @@ -59,6 +60,9 @@ type Server struct { // nsManager управляет жизненным циклом пользовательских namespace-ов. nsManager *cloud.NSManager + + // billing — слой записи статистики вызовов. NoopStore если BILLING_DSN не задан. + billing billing.Store } // Config содержит все параметры для создания Server. @@ -76,6 +80,7 @@ type Config struct { Authenticator auth.Authenticator // слой аутентификации LLMUrl string LLMKey string + Billing billing.Store // слой статистики (NoopStore если не задан) } // NewServer создаёт и настраивает HTTP Server со всеми зависимостями. @@ -96,6 +101,7 @@ func NewServer(cfg Config) *Server { llmURL: cfg.LLMUrl, llmKey: cfg.LLMKey, nsManager: cloud.NewNSManager(cfg.Dyn), + billing: cfg.Billing, } } diff --git a/console/internal/billing/billing.go b/console/internal/billing/billing.go new file mode 100644 index 0000000..d727d68 --- /dev/null +++ b/console/internal/billing/billing.go @@ -0,0 +1,42 @@ +// Package billing — слой записи статистики вызовов функций. +// +// Независим от типа БД: снаружи виден только интерфейс Store. +// Если BILLING_DSN не задан — работает NoopStore (тихо, не крашит). +package billing + +import "time" + +// TriggerType описывает источник вызова. +const ( + TriggerHTTP = "http" // вызов через /fn/... снаружи + TriggerCron = "cron" // вызов по расписанию + TriggerConsole = "console" // вызов через кнопку «Вызов» в UI + TriggerEvent = "event" // lifecycle: create/delete/clone/update +) + +// Invocation — одна запись о вызове или событии функции. +type Invocation struct { + Namespace string // пользовательский namespace (= пользователь) + FunctionName string // имя функции + TriggerType string // TriggerHTTP / TriggerCron / TriggerConsole / TriggerEvent + Route string // HTTP маршрут (/abc123/my-func), пусто для event + HTTPMethod string // GET/POST/... пусто для event + StartedAt time.Time // время начала + DurationMS int64 // длительность в миллисекундах (0 для event) + StatusCode int // HTTP статус ответа (0 для event) + ColdStart bool // true = первый вызов после создания/простоя + RequestBytes int64 // размер тела запроса + ResponseBytes int64 // размер тела ответа + ErrorMsg string // сообщение об ошибке, пусто если успех + RecordedBy string // "console" | "router" + EventType string // для TriggerEvent: "create" | "delete" | "clone" | "update" | "invoke" +} + +// Store — интерфейс записи статистики. +// Реализации: pgStore (PostgreSQL через pgx), NoopStore (заглушка). +type Store interface { + // RecordInvocation записывает одно событие асинхронно (fire-and-forget). + RecordInvocation(inv Invocation) + // Close освобождает ресурсы (пул соединений и т.д.). + Close() +} diff --git a/console/internal/billing/factory.go b/console/internal/billing/factory.go new file mode 100644 index 0000000..dfe7976 --- /dev/null +++ b/console/internal/billing/factory.go @@ -0,0 +1,23 @@ +package billing + +import ( + "context" + "log" + "os" +) + +// NewStore создаёт Store из переменной окружения BILLING_DSN. +// Если DSN пустой — возвращает NoopStore (статистика отключена, сервер работает нормально). +func NewStore() Store { + dsn := os.Getenv("BILLING_DSN") + if dsn == "" { + log.Printf("billing: BILLING_DSN not set, statistics disabled") + return NoopStore{} + } + store, err := NewPostgresStore(context.Background(), dsn) + if err != nil { + log.Printf("billing: failed to connect to PostgreSQL: %v — statistics disabled", err) + return NoopStore{} + } + return store +} diff --git a/console/internal/billing/noop.go b/console/internal/billing/noop.go new file mode 100644 index 0000000..c9980e1 --- /dev/null +++ b/console/internal/billing/noop.go @@ -0,0 +1,8 @@ +package billing + +// NoopStore — заглушка Store, которая тихо игнорирует все записи. +// Используется когда BILLING_DSN не задан. +type NoopStore struct{} + +func (NoopStore) RecordInvocation(_ Invocation) {} +func (NoopStore) Close() {} diff --git a/console/internal/billing/pg.go b/console/internal/billing/pg.go new file mode 100644 index 0000000..cdd19ea --- /dev/null +++ b/console/internal/billing/pg.go @@ -0,0 +1,90 @@ +package billing + +import ( + "context" + "log" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// pgStore — реализация Store поверх PostgreSQL через pgx connection pool. +type pgStore struct { + pool *pgxpool.Pool +} + +// NewPostgresStore создаёт Store с пулом соединений к PostgreSQL. +// dsn — строка вида postgres://user:pass@host:5432/dbname +// При ошибке подключения — возвращает ошибку, вызывающий код решает как реагировать. +func NewPostgresStore(ctx context.Context, dsn string) (Store, error) { + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, err + } + cfg.MaxConns = 4 + cfg.MinConns = 1 + cfg.MaxConnIdleTime = 5 * time.Minute + + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, err + } + // Проверяем живость соединения + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, err + } + log.Printf("billing: connected to PostgreSQL") + return &pgStore{pool: pool}, nil +} + +// RecordInvocation вставляет запись асинхронно — не блокирует основной поток. +func (s *pgStore) RecordInvocation(inv Invocation) { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := s.pool.Exec(ctx, ` + INSERT INTO invocations ( + namespace, function_name, trigger_type, route, http_method, + started_at, duration_ms, status_code, cold_start, + request_bytes, response_bytes, error_msg, recorded_by, event_type + ) VALUES ( + $1,$2,$3,$4,$5, + $6,$7,$8,$9, + $10,$11,$12,$13,$14 + )`, + inv.Namespace, + inv.FunctionName, + inv.TriggerType, + inv.Route, + inv.HTTPMethod, + inv.StartedAt, + inv.DurationMS, + inv.StatusCode, + inv.ColdStart, + inv.RequestBytes, + inv.ResponseBytes, + nullIfEmpty(inv.ErrorMsg), + inv.RecordedBy, + nullIfEmpty(inv.EventType), + ) + if err != nil { + log.Printf("billing: insert invocation: %v", err) + } + }() +} + +// Close закрывает пул соединений. +func (s *pgStore) Close() { + s.pool.Close() + log.Printf("billing: connection pool closed") +} + +// nullIfEmpty возвращает nil для пустой строки (SQL NULL вместо пустой строки). +func nullIfEmpty(s string) interface{} { + if s == "" { + return nil + } + return s +} diff --git a/console/ui/index.html b/console/ui/index.html index e941ee4..b0972c0 100644 --- a/console/ui/index.html +++ b/console/ui/index.html @@ -102,7 +102,7 @@