refactor: enhance bird metrics polling with context support
CI / changes (push) Successful in 8s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 40s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Has been skipped
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Has been skipped
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 15s
CI / docker-go-prime (push) Successful in 24s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 59s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 2m18s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m23s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m20s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m24s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m9s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m22s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m24s
CI / changes (push) Successful in 8s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 40s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Has been skipped
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Has been skipped
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 15s
CI / docker-go-prime (push) Successful in 24s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 59s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 2m18s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m23s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m20s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m24s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m9s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m22s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m24s
Updated the `startBirdMetricsPoller` function to accept a context parameter, allowing for better control over the polling lifecycle. This change was applied in both `evobgp-all` and `evobgp-api` main files. Additionally, modified the `StartBirdProtocolsPoller` function to handle context cancellation, ensuring graceful shutdown of the polling routine. Introduced a new service in the Docker Compose configuration for logging runtime service outputs, improving observability during deployment.
This commit is contained in:
@@ -51,7 +51,7 @@ func main() {
|
|||||||
go render.Run(ctx, renderDeps)
|
go render.Run(ctx, renderDeps)
|
||||||
go deploy.Run(ctx, deployDeps)
|
go deploy.Run(ctx, deployDeps)
|
||||||
|
|
||||||
startBirdMetricsPoller()
|
startBirdMetricsPoller(ctx)
|
||||||
|
|
||||||
httpSrv := &http.Server{
|
httpSrv := &http.Server{
|
||||||
Addr: cfg.HTTPAddr,
|
Addr: cfg.HTTPAddr,
|
||||||
@@ -75,7 +75,7 @@ func main() {
|
|||||||
log.Printf("%s stopped", platform.ServiceName("evobgp-all"))
|
log.Printf("%s stopped", platform.ServiceName("evobgp-all"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func startBirdMetricsPoller() {
|
func startBirdMetricsPoller(ctx context.Context) {
|
||||||
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
|
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
|
||||||
if sock == "" {
|
if sock == "" {
|
||||||
return
|
return
|
||||||
@@ -85,7 +85,7 @@ func startBirdMetricsPoller() {
|
|||||||
interval = d
|
interval = d
|
||||||
}
|
}
|
||||||
bin := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN"))
|
bin := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN"))
|
||||||
observability.StartBirdProtocolsPoller(sock, bin, interval,
|
observability.StartBirdProtocolsPoller(ctx, sock, bin, interval,
|
||||||
func(ctx context.Context, socket, birdcBin string) (string, error) {
|
func(ctx context.Context, socket, birdcBin string) (string, error) {
|
||||||
return birdfmt.ShowProtocols(ctx, socket, birdcBin)
|
return birdfmt.ShowProtocols(ctx, socket, birdcBin)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ func main() {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
observability.SetBuildInfo("0.1.0", strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA")))
|
observability.SetBuildInfo("0.1.0", strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA")))
|
||||||
startBirdMetricsPoller()
|
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
startBirdMetricsPoller(ctx)
|
||||||
|
|
||||||
httpSrv := &http.Server{
|
httpSrv := &http.Server{
|
||||||
Addr: cfg.HTTPAddr,
|
Addr: cfg.HTTPAddr,
|
||||||
Handler: srv.Handler(),
|
Handler: srv.Handler(),
|
||||||
@@ -68,7 +68,7 @@ func main() {
|
|||||||
log.Printf("%s stopped", platform.ServiceName("evobgp-api"))
|
log.Printf("%s stopped", platform.ServiceName("evobgp-api"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func startBirdMetricsPoller() {
|
func startBirdMetricsPoller(ctx context.Context) {
|
||||||
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
|
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
|
||||||
if sock == "" {
|
if sock == "" {
|
||||||
return
|
return
|
||||||
@@ -78,7 +78,7 @@ func startBirdMetricsPoller() {
|
|||||||
interval = d
|
interval = d
|
||||||
}
|
}
|
||||||
bin := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN"))
|
bin := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN"))
|
||||||
observability.StartBirdProtocolsPoller(sock, bin, interval,
|
observability.StartBirdProtocolsPoller(ctx, sock, bin, interval,
|
||||||
func(ctx context.Context, socket, birdcBin string) (string, error) {
|
func(ctx context.Context, socket, birdcBin string) (string, error) {
|
||||||
return birdfmt.ShowProtocols(ctx, socket, birdcBin)
|
return birdfmt.ShowProtocols(ctx, socket, birdcBin)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
# Traefik не находит acme.json и отдаёт дефолтный сертификат до новой выдачи LE.
|
# Traefik не находит acme.json и отдаёт дефолтный сертификат до новой выдачи LE.
|
||||||
# Не использовать `docker compose down -v` без бэкапа. Если раньше был том с префиксом
|
# Не использовать `docker compose down -v` без бэкапа. Если раньше был том с префиксом
|
||||||
# проекта, перенесите acme.json в том evobgp_traefik_letsencrypt.
|
# проекта, перенесите acme.json в том evobgp_traefik_letsencrypt.
|
||||||
|
#
|
||||||
|
# Долгий сбор логов в файлы на хосте: сервис stack-runtime-logs пишет в каталог
|
||||||
|
# ./runtime-logs/ (рядом с этим compose-файлом) по одному файлу на сервис.
|
||||||
|
# Имя проекта в Docker должно совпадать с label com.docker.compose.project:
|
||||||
|
# при смене `name:` или имени стека в Portainer задайте COMPOSE_PROJECT_NAME.
|
||||||
|
# Требуется доступ к docker.sock (полные права на демон — осознанно).
|
||||||
name: evobgp-microvps-full
|
name: evobgp-microvps-full
|
||||||
|
|
||||||
configs:
|
configs:
|
||||||
@@ -216,6 +222,43 @@ services:
|
|||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
|
stack-runtime-logs:
|
||||||
|
image: docker:27-cli
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
evobgp-all:
|
||||||
|
condition: service_started
|
||||||
|
environment:
|
||||||
|
COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-evobgp-microvps-full}
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- type: bind
|
||||||
|
source: ./runtime-logs
|
||||||
|
target: /logs
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
command:
|
||||||
|
- |
|
||||||
|
set -eu
|
||||||
|
mkdir -p /logs
|
||||||
|
PROJECT=$$COMPOSE_PROJECT_NAME
|
||||||
|
SERVICES="postgres nats bird2 evobgp-agent evobgp-all evobgp-web evobgp-edge prometheus"
|
||||||
|
log_one() {
|
||||||
|
svc=$$1
|
||||||
|
f="/logs/$$svc.log"
|
||||||
|
while true; do
|
||||||
|
cid=$$(docker ps -q \
|
||||||
|
-f "label=com.docker.compose.service=$$svc" \
|
||||||
|
-f "label=com.docker.compose.project=$$PROJECT" | head -n1)
|
||||||
|
if [ -n "$$cid" ]; then
|
||||||
|
echo "---- $$(date -u +"%Y-%m-%dT%H:%M:%SZ") attach $$svc $$cid ----" >> "$$f"
|
||||||
|
docker logs -f --timestamps "$$cid" >> "$$f" 2>&1 || true
|
||||||
|
fi
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
}
|
||||||
|
for s in $$SERVICES; do log_one "$$s" & done
|
||||||
|
wait
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
bird_etc:
|
bird_etc:
|
||||||
|
|||||||
+77
-11
@@ -2,7 +2,10 @@ package jobs
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -20,17 +23,17 @@ const (
|
|||||||
|
|
||||||
// Job is the API-facing job model (поля согласованы со схемой job_audit в миграциях; персистенция в БД пока не подключена).
|
// Job is the API-facing job model (поля согласованы со схемой job_audit в миграциях; персистенция в БД пока не подключена).
|
||||||
type Job struct {
|
type Job struct {
|
||||||
ID string
|
ID string
|
||||||
TenantID string
|
TenantID string
|
||||||
Kind string
|
Kind string
|
||||||
Status string
|
Status string
|
||||||
IdempotencyKey *string
|
IdempotencyKey *string
|
||||||
ModuleID *string
|
ModuleID *string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
StartedAt *time.Time
|
StartedAt *time.Time
|
||||||
FinishedAt *time.Time
|
FinishedAt *time.Time
|
||||||
Error *string
|
Error *string
|
||||||
ProgressPct *int16
|
ProgressPct *int16
|
||||||
Meta map[string]any
|
Meta map[string]any
|
||||||
cancelRequested bool
|
cancelRequested bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -148,6 +151,25 @@ func (j *Job) Snapshot() map[string]any {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var jobRegistryMaxJobsOnce sync.Once
|
||||||
|
var jobRegistryMaxJobs int
|
||||||
|
|
||||||
|
// registryMaxJobsFromEnv returns EVOBGP_JOB_REGISTRY_MAX_JOBS once (0 = без лимита, только завершённые джобы вытесняются).
|
||||||
|
func registryMaxJobsFromEnv() int {
|
||||||
|
jobRegistryMaxJobsOnce.Do(func() {
|
||||||
|
s := strings.TrimSpace(os.Getenv("EVOBGP_JOB_REGISTRY_MAX_JOBS"))
|
||||||
|
if s == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(s)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jobRegistryMaxJobs = n
|
||||||
|
})
|
||||||
|
return jobRegistryMaxJobs
|
||||||
|
}
|
||||||
|
|
||||||
// Registry — in-memory очередь и индекс по idempotency в процессе, где поднят HTTP API (evobgp-api и evobgp-all).
|
// Registry — in-memory очередь и индекс по idempotency в процессе, где поднят HTTP API (evobgp-api и evobgp-all).
|
||||||
// Отдельные воркеры в reference-профиле не разделяют память с API: scheduler дергает refresh по HTTP; см. docs/architecture.md.
|
// Отдельные воркеры в reference-профиле не разделяют память с API: scheduler дергает refresh по HTTP; см. docs/architecture.md.
|
||||||
// Запись задач в PostgreSQL job_audit + SKIP LOCKED / внешний брокер — планируемое расширение (архитектурный план §2, §7.10).
|
// Запись задач в PostgreSQL job_audit + SKIP LOCKED / внешний брокер — планируемое расширение (архитектурный план §2, §7.10).
|
||||||
@@ -171,11 +193,54 @@ func NewRegistry(workerStart func(j *Job)) *Registry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pruneTerminalIfOver удаляет самые старые завершённые джобы (succeeded/failed/cancelled), пока len(byID) > maxJobs.
|
||||||
|
func (r *Registry) pruneTerminalIfOver(maxJobs int) {
|
||||||
|
if r == nil || maxJobs <= 0 || len(r.byID) <= maxJobs {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type fin struct {
|
||||||
|
j *Job
|
||||||
|
t time.Time
|
||||||
|
}
|
||||||
|
var cands []fin
|
||||||
|
for _, j := range r.byID {
|
||||||
|
st := j.statusLocked()
|
||||||
|
if st != StatusSucceeded && st != StatusFailed && st != StatusCancelled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
j.mu.Lock()
|
||||||
|
ft := j.FinishedAt
|
||||||
|
j.mu.Unlock()
|
||||||
|
if ft == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cands = append(cands, fin{j: j, t: *ft})
|
||||||
|
}
|
||||||
|
need := len(r.byID) - maxJobs
|
||||||
|
if need <= 0 || len(cands) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sort.Slice(cands, func(i, j int) bool { return cands[i].t.Before(cands[j].t) })
|
||||||
|
if need > len(cands) {
|
||||||
|
need = len(cands)
|
||||||
|
}
|
||||||
|
for i := 0; i < need; i++ {
|
||||||
|
v := cands[i].j
|
||||||
|
delete(r.byID, v.ID)
|
||||||
|
if v.IdempotencyKey != nil && *v.IdempotencyKey != "" {
|
||||||
|
delete(r.byIdempo, idempoKey{tenant: v.TenantID, key: *v.IdempotencyKey})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Enqueue creates a job or returns an existing one for the same idempotency key.
|
// Enqueue creates a job or returns an existing one for the same idempotency key.
|
||||||
func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, moduleID *string, meta map[string]any) (*Job, bool, error) {
|
func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, moduleID *string, meta map[string]any) (*Job, bool, error) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
maxJobs := registryMaxJobsFromEnv()
|
||||||
|
r.pruneTerminalIfOver(maxJobs)
|
||||||
|
|
||||||
if idempotencyKey != nil && *idempotencyKey != "" {
|
if idempotencyKey != nil && *idempotencyKey != "" {
|
||||||
k := idempoKey{tenant: tenantID, key: *idempotencyKey}
|
k := idempoKey{tenant: tenantID, key: *idempotencyKey}
|
||||||
if existing, ok := r.byIdempo[k]; ok {
|
if existing, ok := r.byIdempo[k]; ok {
|
||||||
@@ -197,6 +262,7 @@ func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, module
|
|||||||
r.byIdempo[idempoKey{tenant: tenantID, key: *idempotencyKey}] = j
|
r.byIdempo[idempoKey{tenant: tenantID, key: *idempotencyKey}] = j
|
||||||
}
|
}
|
||||||
r.byID[j.ID] = j
|
r.byID[j.ID] = j
|
||||||
|
r.pruneTerminalIfOver(maxJobs)
|
||||||
|
|
||||||
if r.workerStart != nil {
|
if r.workerStart != nil {
|
||||||
go r.workerStart(j)
|
go r.workerStart(j)
|
||||||
|
|||||||
+37
-37
@@ -158,8 +158,8 @@ func (w *Worker) finishModuleRefreshSuccess(j *Job, rev string) {
|
|||||||
deferDeploy := w.Registry.CountOtherActiveModuleRefresh(j.TenantID, j.ID) > 0
|
deferDeploy := w.Registry.CountOtherActiveModuleRefresh(j.TenantID, j.ID) > 0
|
||||||
if deferDeploy {
|
if deferDeploy {
|
||||||
j.mergeMeta(map[string]any{
|
j.mergeMeta(map[string]any{
|
||||||
"deploy_apply_deferred": true,
|
"deploy_apply_deferred": true,
|
||||||
"deploy_apply_defer_reason": "parallel_module_refresh",
|
"deploy_apply_defer_reason": "parallel_module_refresh",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
j.Succeed()
|
j.Succeed()
|
||||||
@@ -324,19 +324,6 @@ func (w *Worker) buildRevisionLogEntries(tenantID, revID string) ([]map[string]a
|
|||||||
return nil, 0, fmt.Errorf("store not configured")
|
return nil, 0, fmt.Errorf("store not configured")
|
||||||
}
|
}
|
||||||
commLabels := buildCommunityLabelMap(w.Store, tenantID)
|
commLabels := buildCommunityLabelMap(w.Store, tenantID)
|
||||||
var all []store.PrefixRow
|
|
||||||
cursor := ""
|
|
||||||
for {
|
|
||||||
rows, next, more := w.Store.ListRevisionPrefixes(tenantID, revID, cursor, 1000)
|
|
||||||
all = append(all, rows...)
|
|
||||||
if !more {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
cursor = next
|
|
||||||
if strings.TrimSpace(cursor) == "" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
type agg struct {
|
type agg struct {
|
||||||
kind string
|
kind string
|
||||||
source string
|
source string
|
||||||
@@ -345,22 +332,35 @@ func (w *Worker) buildRevisionLogEntries(tenantID, revID string) ([]map[string]a
|
|||||||
sample []string
|
sample []string
|
||||||
}
|
}
|
||||||
groups := map[string]*agg{}
|
groups := map[string]*agg{}
|
||||||
for _, p := range all {
|
total := 0
|
||||||
src := strings.TrimSpace(p.Source)
|
cursor := ""
|
||||||
comm := "none"
|
for {
|
||||||
if p.CommunityID != nil && strings.TrimSpace(*p.CommunityID) != "" {
|
rows, next, more := w.Store.ListRevisionPrefixes(tenantID, revID, cursor, 1000)
|
||||||
comm = strings.TrimSpace(*p.CommunityID)
|
for _, p := range rows {
|
||||||
|
total++
|
||||||
|
src := strings.TrimSpace(p.Source)
|
||||||
|
comm := "none"
|
||||||
|
if p.CommunityID != nil && strings.TrimSpace(*p.CommunityID) != "" {
|
||||||
|
comm = strings.TrimSpace(*p.CommunityID)
|
||||||
|
}
|
||||||
|
kind, sourceName := classifySource(src)
|
||||||
|
k := kind + "|" + sourceName + "|" + comm
|
||||||
|
g, ok := groups[k]
|
||||||
|
if !ok {
|
||||||
|
g = &agg{kind: kind, source: sourceName, community: comm}
|
||||||
|
groups[k] = g
|
||||||
|
}
|
||||||
|
g.count++
|
||||||
|
if len(g.sample) < 5 {
|
||||||
|
g.sample = append(g.sample, p.Prefix)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
kind, sourceName := classifySource(src)
|
if !more {
|
||||||
k := kind + "|" + sourceName + "|" + comm
|
break
|
||||||
g, ok := groups[k]
|
|
||||||
if !ok {
|
|
||||||
g = &agg{kind: kind, source: sourceName, community: comm}
|
|
||||||
groups[k] = g
|
|
||||||
}
|
}
|
||||||
g.count++
|
cursor = next
|
||||||
if len(g.sample) < 5 {
|
if strings.TrimSpace(cursor) == "" {
|
||||||
g.sample = append(g.sample, p.Prefix)
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
keys := make([]string, 0, len(groups))
|
keys := make([]string, 0, len(groups))
|
||||||
@@ -374,16 +374,16 @@ func (w *Worker) buildRevisionLogEntries(tenantID, revID string) ([]map[string]a
|
|||||||
cl := resolveCommunityLabel(g.community, commLabels)
|
cl := resolveCommunityLabel(g.community, commLabels)
|
||||||
msg := humanLogMessage(g.kind, g.source, g.count, cl, g.sample)
|
msg := humanLogMessage(g.kind, g.source, g.count, cl, g.sample)
|
||||||
out = append(out, map[string]any{
|
out = append(out, map[string]any{
|
||||||
"kind": g.kind,
|
"kind": g.kind,
|
||||||
"source": g.source,
|
"source": g.source,
|
||||||
"community": g.community,
|
"community": g.community,
|
||||||
"community_label": cl,
|
"community_label": cl,
|
||||||
"prefix_count": g.count,
|
"prefix_count": g.count,
|
||||||
"sample": g.sample,
|
"sample": g.sample,
|
||||||
"message": msg,
|
"message": msg,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out, len(all), nil
|
return out, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func classifySource(src string) (kind, name string) {
|
func classifySource(src string) (kind, name string) {
|
||||||
|
|||||||
@@ -196,14 +196,15 @@ func (s *statusRecorder) WriteHeader(code int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// StartBirdProtocolsPoller runs birdc "show protocols" on interval when socket is non-empty.
|
// StartBirdProtocolsPoller runs birdc "show protocols" on interval when socket is non-empty.
|
||||||
func StartBirdProtocolsPoller(socket string, birdcPath string, interval time.Duration, showFn func(ctx context.Context, socket, birdcBin string) (string, error), countFn func(output string) int) {
|
// Горутина завершается при отмене ctx (корректное завершение вместе с процессом API).
|
||||||
|
func StartBirdProtocolsPoller(ctx context.Context, socket string, birdcPath string, interval time.Duration, showFn func(ctx context.Context, socket, birdcBin string) (string, error), countFn func(output string) int) {
|
||||||
socket = trimSpace(socket)
|
socket = trimSpace(socket)
|
||||||
if socket == "" || interval <= 0 || showFn == nil || countFn == nil {
|
if ctx == nil || socket == "" || interval <= 0 || showFn == nil || countFn == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
scrape := func() {
|
scrape := func() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
sctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
out, err := showFn(ctx, socket, birdcPath)
|
out, err := showFn(sctx, socket, birdcPath)
|
||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
SetBirdSessionMetrics(0, false)
|
SetBirdSessionMetrics(0, false)
|
||||||
@@ -215,8 +216,13 @@ func StartBirdProtocolsPoller(socket string, birdcPath string, interval time.Dur
|
|||||||
scrape()
|
scrape()
|
||||||
t := time.NewTicker(interval)
|
t := time.NewTicker(interval)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
for range t.C {
|
for {
|
||||||
scrape()
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
scrape()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-13
@@ -3,10 +3,17 @@ package pipeline
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Защита от pathological JSON: глубокая рекурсия при обходе и взрыв числа узлов по пути.
|
||||||
|
const (
|
||||||
|
maxJSONWalkDepth = 512
|
||||||
|
maxJSONPathBreadth = 50000
|
||||||
|
)
|
||||||
|
|
||||||
// ParseCIDRLines extracts unique IPv4/IPv6 CIDRs from plain text (one per line, # comments, empty lines skipped).
|
// ParseCIDRLines extracts unique IPv4/IPv6 CIDRs from plain text (one per line, # comments, empty lines skipped).
|
||||||
func ParseCIDRLines(body string) []netip.Prefix {
|
func ParseCIDRLines(body string) []netip.Prefix {
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
@@ -47,7 +54,10 @@ func parseCIDRsFromJSON(body, prefixPath string) ([]netip.Prefix, error) {
|
|||||||
if err := json.Unmarshal([]byte(body), &root); err != nil {
|
if err := json.Unmarshal([]byte(body), &root); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
values := jsonValuesAtPath(root, prefixPath)
|
values, err := jsonValuesAtPath(root, prefixPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
var out []netip.Prefix
|
var out []netip.Prefix
|
||||||
for _, raw := range values {
|
for _, raw := range values {
|
||||||
@@ -66,10 +76,10 @@ func parseCIDRsFromJSON(body, prefixPath string) ([]netip.Prefix, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func jsonValuesAtPath(root any, prefixPath string) []string {
|
func jsonValuesAtPath(root any, prefixPath string) ([]string, error) {
|
||||||
path := strings.TrimSpace(prefixPath)
|
path := strings.TrimSpace(prefixPath)
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return flattenJSONStrings(root)
|
return flattenJSONStrings(root, 0)
|
||||||
}
|
}
|
||||||
parts := strings.Split(path, ".")
|
parts := strings.Split(path, ".")
|
||||||
nodes := []any{root}
|
nodes := []any{root}
|
||||||
@@ -98,36 +108,54 @@ func jsonValuesAtPath(root any, prefixPath string) []string {
|
|||||||
}
|
}
|
||||||
next = append(next, child)
|
next = append(next, child)
|
||||||
}
|
}
|
||||||
|
if len(next) > maxJSONPathBreadth {
|
||||||
|
return nil, fmt.Errorf("json path: слишком много узлов на шаге (>%d)", maxJSONPathBreadth)
|
||||||
|
}
|
||||||
nodes = next
|
nodes = next
|
||||||
if len(nodes) == 0 {
|
if len(nodes) == 0 {
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var out []string
|
var out []string
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
out = append(out, flattenJSONStrings(n)...)
|
part, err := flattenJSONStrings(n, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, part...)
|
||||||
}
|
}
|
||||||
return out
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func flattenJSONStrings(v any) []string {
|
func flattenJSONStrings(v any, depth int) ([]string, error) {
|
||||||
|
if depth > maxJSONWalkDepth {
|
||||||
|
return nil, fmt.Errorf("json: глубина вложенности превышает %d", maxJSONWalkDepth)
|
||||||
|
}
|
||||||
switch x := v.(type) {
|
switch x := v.(type) {
|
||||||
case string:
|
case string:
|
||||||
return []string{strings.TrimSpace(x)}
|
return []string{strings.TrimSpace(x)}, nil
|
||||||
case []any:
|
case []any:
|
||||||
var out []string
|
var out []string
|
||||||
for _, item := range x {
|
for _, item := range x {
|
||||||
out = append(out, flattenJSONStrings(item)...)
|
part, err := flattenJSONStrings(item, depth+1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, part...)
|
||||||
}
|
}
|
||||||
return out
|
return out, nil
|
||||||
case map[string]any:
|
case map[string]any:
|
||||||
var out []string
|
var out []string
|
||||||
for _, item := range x {
|
for _, item := range x {
|
||||||
out = append(out, flattenJSONStrings(item)...)
|
part, err := flattenJSONStrings(item, depth+1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, part...)
|
||||||
}
|
}
|
||||||
return out
|
return out, nil
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtractCIDRs_JSONNested(t *testing.T) {
|
||||||
|
body := `{"a":{"b":{"c":"192.0.2.0/24"}}}`
|
||||||
|
pfx, err := ExtractCIDRs(body, "json", "a.b.c")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(pfx) != 1 || pfx[0].String() != "192.0.2.0/24" {
|
||||||
|
t.Fatalf("got %#v", pfx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractCIDRs_JSONDepthLimit(t *testing.T) {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(`{"x":`)
|
||||||
|
for i := 0; i < maxJSONWalkDepth+4; i++ {
|
||||||
|
b.WriteString(`{"k":`)
|
||||||
|
}
|
||||||
|
b.WriteString(`"192.0.2.1"`)
|
||||||
|
for i := 0; i < maxJSONWalkDepth+4; i++ {
|
||||||
|
b.WriteByte('}')
|
||||||
|
}
|
||||||
|
b.WriteByte('}')
|
||||||
|
_, err := ExtractCIDRs(b.String(), "json", "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected depth error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractCIDRs_JSONPathBreadth(t *testing.T) {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(`{"items":{"x":[`)
|
||||||
|
for i := 0; i < maxJSONPathBreadth+1; i++ {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteByte(',')
|
||||||
|
}
|
||||||
|
b.WriteString(`"192.0.2.0/24"`)
|
||||||
|
}
|
||||||
|
b.WriteString(`]}}`)
|
||||||
|
_, err := ExtractCIDRs(b.String(), "json", "items.x[]")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected breadth error")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user