Netpulse_SasS/server/internal/grpcapi/streams.go
byrsapty 3816e2cfcf Етап 7: збір конфігів запрацював наскрізно
Профілі були даними без виконавця. Тепер ланцюг замкнено: черга в БД →
диспетчер у AgentService → зонд по SSH/Telnet → вивантаження стрімом →
звірка sha256 і дедуплікація.

Агент (internal/ncmx):
- усе побудовано навколо пошуку промпту: у консолі немає ані коду
  завершення, ані довжини відповіді
- промпт шукається лише в хвості 512 байтів, інакше "banner motd #"
  обривав би збір на середині конфігу
- дедлайн на паузу між байтами, а не на всю операцію: збір із шасі
  триває хвилини, а тиша означає завислий пристрій або хибний regex
- ключі SSH обладнання не звіряються свідомо: залізо міняє їх з кожною
  прошивкою, і known_hosts на сотні пристроїв означав би не збирати
  конфіги зовсім

Сервер: черга ncm.jobs із FOR UPDATE SKIP LOCKED (два екземпляри не
надішлють одне завдання двічі), диспетчер, прибирання завислих.

Знайдено живим прогоном:
- сервер ігнорував поле encoding: агент стискав gzip і рахував sha256
  від оригіналу, сервер рахував від стиснених байтів і відхиляв кожен
  бекап. Поле в контракті з Етапу 2, реалізації не було — інтеграційний
  тест користувався "none" і повз дірку проходив
- промпт обрізався не по рядку: шаблон [>#]\s*$ ловить лише символ, і
  в конфізі лишалось ім'я пристрою окремим рядком
- у конфіг потрапляли escape-послідовності bash і подвоєні \r\r\n від
  PTY: 295 байтів / 12 рядків замість 286 / 10. Після виправлення —
  285 / 10, різниця лише у фінальному переводі рядка

Живий прогін проти стенду: success, конфіг 285 байтів, повторний збір —
unchanged без другої версії.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 12:02:50 +03:00

405 lines
14 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 (
"bytes"
"compress/gzip"
"context"
"errors"
"fmt"
"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)
s.refreshInterfaceChecks(ctx, agent, rep)
return &npv1.DiscoveryAck{
Accepted: true,
NeighborsResolved: uint32(st.NeighborsResolved),
LinksCreated: uint32(st.LinksCreated),
}, nil
}
// refreshInterfaceChecks створює або оновлює snmp.if-чеки за щойно
// виявленими інтерфейсами й одразу штовхає зміну живому зонду.
//
// Без цього кроку автовиявлення наповнює inv.interfaces, але ніхто їх
// не опитує: на мапі є лінки й немає трафіку. Чекати наступного
// перепідключення агента, щоб він забрав новий план, — це години
// порожніх графіків після кожного нового комутатора.
func (s *Service) refreshInterfaceChecks(ctx context.Context, agent *store.Agent, rep *npv1.DiscoveryReport) {
seen := make(map[string]bool)
for _, r := range rep.GetInterfaces() {
if id := r.GetDeviceId(); id != "" {
seen[id] = true
}
}
if len(seen) == 0 {
return
}
var (
tasks []*npv1.Task
deviceIDs []string
)
for deviceID := range seen {
task, err := s.store.EnsureInterfaceChecks(ctx, agent, deviceID)
if err != nil {
// Обрізаний список портів — теж помилка, але чек при цьому
// створено, тому це попередження, а не привід зупинитись.
s.log.Warn("snmp.if-чек", "device", deviceID, "err", err)
}
if task != nil {
tasks = append(tasks, task)
deviceIDs = append(deviceIDs, deviceID)
}
}
if len(tasks) == 0 {
return
}
// Новий хеш обов'язковий: інакше після реконекту агент доповість
// старий, сервер вирішить, що план застарів, і перезаллє все.
hash, err := s.store.PlanHash(ctx, agent)
if err != nil {
s.log.Warn("не вдалося перерахувати хеш плану", "agent", agent.ID, "err", err)
return
}
devices, err := s.store.DeviceTargets(ctx, agent, deviceIDs)
if err != nil {
s.log.Warn("не вдалося зібрати описи пристроїв", "agent", agent.ID, "err", err)
return
}
delivered := s.PushToAgent(agent.ID, &npv1.ControlDown{
Payload: &npv1.ControlDown_TaskDelta{TaskDelta: &npv1.TaskDelta{
PlanHash: hash,
Upsert: tasks,
UpsertDevices: devices,
}},
})
s.log.Info("snmp.if-чеки оновлено",
"agent", agent.ID, "задач", len(tasks), адісланоаживо", delivered)
}
// ---------------------------------------------------------------------
// Конфігурації
// ---------------------------------------------------------------------
// 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())
// Завдання треба закрити тут: інакше воно висітиме в
// 'running' до прибиральника, а хост увесь цей час
// вважатиметься таким, що збирається.
_ = s.store.FinishConfigJob(ctx, header.GetJobId(), "failed",
tr.GetError().GetMessage(), tr.GetTranscript())
return stream.SendAndClose(&npv1.ConfigReceipt{
JobId: header.GetJobId(), Accepted: false, Error: tr.GetError(),
})
}
// Розтискаємо ДО звірки суми: sha256 рахується від тіла
// конфігу, а не від його пакування. Інакше зміна рівня
// стиснення виглядала б як зміна конфігу, а gzip-байти
// потрапили б у Git замість тексту.
plain, err := decodeBody(body, header.GetEncoding())
if err != nil {
s.log.Warn("не вдалося розпакувати конфіг",
"agent", agent.ID, "encoding", header.GetEncoding(), "err", err)
_ = s.store.FinishConfigJob(ctx, header.GetJobId(), "failed", err.Error(),
tr.GetTranscript())
return stream.SendAndClose(&npv1.ConfigReceipt{
JobId: header.GetJobId(),
Accepted: false,
Error: &npv1.Error{
Code: "bad_encoding",
Message: err.Error(),
},
})
}
body = plain
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())
_ = s.store.FinishConfigJob(ctx, header.GetJobId(), "failed",
"тіло не відповідає заявленому sha256", tr.GetTranscript())
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)
// «unchanged» — це успіх, а не відсутність результату:
// пристрій опитано, конфіг звірено, нового коміту просто
// не потрібно. Окремий статус потрібен, щоб у журналі було
// видно, коли конфіг востаннє СПРАВДІ мінявся.
finalStatus := "success"
if outcome.Unchanged {
finalStatus = "unchanged"
}
_ = s.store.FinishConfigJob(ctx, header.GetJobId(), finalStatus, "", tr.GetTranscript())
return stream.SendAndClose(&npv1.ConfigReceipt{
JobId: header.GetJobId(),
Accepted: outcome.Accepted,
Unchanged: outcome.Unchanged,
ConfigId: outcome.ConfigID,
CommitSha: outcome.CommitSHA,
})
}
}
}
// decodeBody розпаковує тіло конфігу за оголошеним кодуванням.
//
// Порожнє значення й "none" означають одне й те саме: агенти старших
// версій поля не заповнювали, і вимагати його зараз означало б
// відхиляти їхні бекапи.
func decodeBody(body []byte, encoding string) ([]byte, error) {
switch encoding {
case "", "none":
return body, nil
case "gzip":
zr, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("gzip: %w", err)
}
defer zr.Close()
// Ліміт той самий, що й на прийом: розпакування — улюблений
// спосіб перетворити мегабайт трафіку на гігабайт пам'яті.
plain, err := io.ReadAll(io.LimitReader(zr, maxConfigBytes))
if err != nil {
return nil, fmt.Errorf("gzip: %w", err)
}
return plain, nil
default:
return nil, fmt.Errorf("невідоме кодування %q", encoding)
}
}
// maxConfigBytes — стеля на розпакований конфіг.
const maxConfigBytes = 64 << 20