Профілі були даними без виконавця. Тепер ланцюг замкнено: черга в БД → диспетчер у 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>
405 lines
14 KiB
Go
405 lines
14 KiB
Go
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
|