Netpulse_SasS/server/internal/grpcapi/streams.go
zotac 8a92ef8a45 Етап 2: серверна сторона AgentService
Автентифікація зондів за токеном, побудова TaskPlan із детермінованим
schedule_offset, видача розшифрованих креденшелів із TTL, запис телеметрії
в гіпертаблиці, резолвер сусідів LLDP/CDP у topo.links, прийом конфігів.

Ізоляція тенантів робиться двічі — RLS плюс явний предикат tenant_id, бо
RLS не працює на гіпертаблях, а саме туди йде вся телеметрія.

Агент: -token і передача його в метаданих; MarkAllPending() перереєстровує
серії на початку сесії замість обнуляти нумерацію й губити буфер.

Перевірено на Debian 13 / PG 17.11 / TimescaleDB 2.29.1: 11 інтеграційних
тестів проти живої БД (-race), плюс живий прогін справжнього агента проти
справжнього сервера — телеметрія, статус пристрою, heartbeat у базі.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 04:13:40 +03:00

268 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package grpcapi
import (
"context"
"errors"
"io"
"github.com/netpulse/netpulse/server/internal/store"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// ---------------------------------------------------------------------
// Телеметрія
// ---------------------------------------------------------------------
func (s *Service) StreamTelemetry(stream npv1.AgentService_StreamTelemetryServer) error {
ctx := stream.Context()
agent, err := agentFrom(ctx)
if err != nil {
return err
}
// Таблиця series_ref живе рівно стільки, скільки цей стрім.
// Агент знає про це й перереєстровує серії на початку кожної сесії.
table := store.NewSeriesTable()
var acked uint64
for {
batch, err := stream.Recv()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return err
}
st, writeErr := s.store.WriteBatch(ctx, agent, batch, table)
var unknown *store.ErrUnknownSeriesRef
if errors.As(writeErr, &unknown) {
// Ми загубили відповідність — чесно просимо перереєстрацію
// замість тихо викинути дані.
s.log.Warn("невідомий series_ref", "agent", agent.ID, "ref", unknown.Ref)
if err := stream.Send(&npv1.TelemetryAck{
AckedThroughBatchId: acked,
ResetSeriesTable: true,
MaxInFlight: uint32(agent.Limits.MaxInFlight),
Error: &npv1.Error{
Code: "unknown_series_ref",
Message: unknown.Error(),
Retryable: true,
},
}); err != nil {
return err
}
continue
}
if writeErr != nil {
s.log.Error("батч не записався", "agent", agent.ID,
"batch", batch.GetBatchId(), "err", writeErr)
// Батч не підтверджуємо: агент перешле його після
// реконекту, а ON CONFLICT DO NOTHING зробить це безпечним.
if err := stream.Send(&npv1.TelemetryAck{
AckedThroughBatchId: acked,
NackBatchIds: []uint64{batch.GetBatchId()},
MaxInFlight: uint32(agent.Limits.MaxInFlight),
Error: &npv1.Error{
Code: "write_failed", Message: writeErr.Error(), Retryable: true,
},
}); err != nil {
return err
}
continue
}
if batch.GetBatchId() > acked {
acked = batch.GetBatchId()
}
s.log.Debug("батч записано", "agent", agent.ID, "batch", batch.GetBatchId(),
"samples", st.Samples, "icmp", st.Icmp, "interfaces", st.Interfaces)
if err := stream.Send(&npv1.TelemetryAck{
AckedThroughBatchId: acked,
MaxInFlight: uint32(agent.Limits.MaxInFlight),
}); err != nil {
return err
}
}
}
// ---------------------------------------------------------------------
// Журнали
// ---------------------------------------------------------------------
func (s *Service) StreamLogs(stream npv1.AgentService_StreamLogsServer) error {
ctx := stream.Context()
agent, err := agentFrom(ctx)
if err != nil {
return err
}
var acked uint64
for {
batch, err := stream.Recv()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return err
}
if _, err := s.store.WriteLogs(ctx, agent, batch); err != nil {
s.log.Error("журнали не записались", "agent", agent.ID, "err", err)
if err := stream.Send(&npv1.LogAck{
AckedThroughBatchId: acked,
Error: &npv1.Error{Code: "write_failed", Message: err.Error(), Retryable: true},
}); err != nil {
return err
}
continue
}
if batch.GetDropped() > 0 {
s.log.Warn("зонд відкинув події через ліміт",
"agent", agent.ID, "dropped", batch.GetDropped())
}
if batch.GetBatchId() > acked {
acked = batch.GetBatchId()
}
if err := stream.Send(&npv1.LogAck{AckedThroughBatchId: acked}); err != nil {
return err
}
}
}
// ---------------------------------------------------------------------
// Автовиявлення
// ---------------------------------------------------------------------
func (s *Service) ReportDiscovery(ctx context.Context, rep *npv1.DiscoveryReport) (*npv1.DiscoveryAck, error) {
agent, err := agentFrom(ctx)
if err != nil {
return nil, err
}
st, err := s.store.ApplyDiscovery(ctx, agent, rep)
if err != nil {
s.log.Error("звіт автовиявлення не застосувався", "agent", agent.ID, "err", err)
return &npv1.DiscoveryAck{
Accepted: false,
Error: &npv1.Error{Code: "apply_failed", Message: err.Error(), Retryable: true},
}, nil
}
s.log.Info("автовиявлення застосовано",
"agent", agent.ID,
"сусідів", st.NeighborsSeen, "зіставлено", st.NeighborsResolved,
інків_створено", st.LinksCreated, інків_оновлено", st.LinksUpdated)
return &npv1.DiscoveryAck{
Accepted: true,
NeighborsResolved: uint32(st.NeighborsResolved),
LinksCreated: uint32(st.LinksCreated),
}, nil
}
// ---------------------------------------------------------------------
// Конфігурації
// ---------------------------------------------------------------------
// UploadConfig збирає конфіг із чанків: header → chunk* → trailer.
func (s *Service) UploadConfig(stream npv1.AgentService_UploadConfigServer) error {
ctx := stream.Context()
agent, err := agentFrom(ctx)
if err != nil {
return err
}
var (
header *npv1.ConfigHeader
body []byte
wantSeq uint32
)
for {
msg, err := stream.Recv()
if errors.Is(err, io.EOF) {
return status.Error(codes.InvalidArgument, "стрім завершився без trailer")
}
if err != nil {
return err
}
switch p := msg.Part.(type) {
case *npv1.ConfigUpload_Header:
header = p.Header
case *npv1.ConfigUpload_Chunk:
if header == nil {
return status.Error(codes.InvalidArgument, "chunk раніше за header")
}
// Порядок важливий: конфіг склеюється байт-у-байт, і
// переставлені чанки дали б тихо зіпсований бекап.
if p.Chunk.GetSequence() != wantSeq {
return status.Errorf(codes.InvalidArgument,
"чанки не по порядку: чекали %d, отримали %d", wantSeq, p.Chunk.GetSequence())
}
wantSeq++
body = append(body, p.Chunk.GetData()...)
case *npv1.ConfigUpload_Trailer:
if header == nil {
return status.Error(codes.InvalidArgument, "trailer без header")
}
tr := p.Trailer
if !tr.GetSuccess() {
s.log.Warn("зонд не зміг зібрати конфіг",
"agent", agent.ID, "job", header.GetJobId(),
"err", tr.GetError().GetMessage())
return stream.SendAndClose(&npv1.ConfigReceipt{
JobId: header.GetJobId(), Accepted: false, Error: tr.GetError(),
})
}
outcome, err := s.store.StoreConfig(ctx, agent, store.ConfigSubmission{
JobID: header.GetJobId(),
DeviceID: header.GetDeviceId(),
ConfigType: header.GetConfigType(),
Body: body,
ClaimedHash: tr.GetContentSha256(),
}, s.ring)
if err != nil {
return status.Errorf(codes.Internal, "збереження конфігу: %v", err)
}
if errors.Is(outcome.Err, store.ErrChecksumMismatch) {
s.log.Warn("конфіг із розбіжною контрольною сумою відхилено",
"agent", agent.ID, "device", header.GetDeviceId())
return stream.SendAndClose(&npv1.ConfigReceipt{
JobId: header.GetJobId(),
Accepted: false,
Error: &npv1.Error{
Code: "checksum_mismatch", Retryable: true,
Message: "тіло не відповідає заявленому sha256",
},
})
}
s.log.Info("конфіг прийнято",
"agent", agent.ID, "device", header.GetDeviceId(),
"розмір", len(body), "без_змін", outcome.Unchanged)
return stream.SendAndClose(&npv1.ConfigReceipt{
JobId: header.GetJobId(),
Accepted: outcome.Accepted,
Unchanged: outcome.Unchanged,
ConfigId: outcome.ConfigID,
CommitSha: outcome.CommitSHA,
})
}
}
}