Автентифікація зондів за токеном, побудова 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>
268 lines
8.1 KiB
Go
268 lines
8.1 KiB
Go
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,
|
||
})
|
||
}
|
||
}
|
||
}
|