Профілі були даними без виконавця. Тепер ланцюг замкнено: черга в БД → диспетчер у 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>
180 lines
6 KiB
Go
180 lines
6 KiB
Go
package session
|
||
|
||
import (
|
||
"bytes"
|
||
"compress/gzip"
|
||
"context"
|
||
"errors"
|
||
"time"
|
||
|
||
"github.com/netpulse/netpulse/agent/internal/ncmx"
|
||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||
"google.golang.org/protobuf/types/known/durationpb"
|
||
"google.golang.org/protobuf/types/known/timestamppb"
|
||
)
|
||
|
||
// errNoConnection — сесія обірвалась, поки збирали конфіг.
|
||
var errNoConnection = errors.New("немає живого зʼєднання з сервером")
|
||
|
||
// chunkSize — розмір шматка вивантаження.
|
||
//
|
||
// 64 КБ: помітно менше за типовий ліміт gRPC-повідомлення, тому навіть
|
||
// конфіг на кілька МБ проходить без налаштування транспорту, і при
|
||
// цьому не так дрібно, щоб платити накладними за кожен рядок.
|
||
const chunkSize = 64 << 10
|
||
|
||
// runConfigJob знімає конфіг і вивантажує його на сервер.
|
||
//
|
||
// Виконується в окремій горутині: збір конфігу з великого шасі триває
|
||
// хвилини, і тримати на ньому контрольний цикл означало б не відповідати
|
||
// на ping і бути визнаним мертвим саме тоді, коли агент найбільше
|
||
// зайнятий корисною роботою.
|
||
func (s *Session) runConfigJob(ctx context.Context, job *npv1.ConfigJob) {
|
||
log := s.log.With("job_id", job.GetJobId(),
|
||
"device", job.GetDevice().GetName(),
|
||
"config_type", job.GetConfigType())
|
||
|
||
log.Info("збір конфігу почався")
|
||
|
||
res, err := ncmx.Collect(ctx, job)
|
||
if err != nil {
|
||
log.Error("збір конфігу", "помилка", err)
|
||
s.uploadFailure(ctx, job, err, res.Transcript)
|
||
return
|
||
}
|
||
|
||
log.Info("конфіг знято",
|
||
"байтів", len(res.Body), "рядків", res.LineCount,
|
||
"тривалість", res.Duration.Round(time.Millisecond))
|
||
|
||
if err := s.uploadConfig(ctx, job, res); err != nil {
|
||
log.Error("вивантаження конфігу", "помилка", err)
|
||
}
|
||
}
|
||
|
||
func (s *Session) uploadConfig(ctx context.Context, job *npv1.ConfigJob, res ncmx.Result) error {
|
||
conn := s.client.Load()
|
||
if conn == nil {
|
||
return errNoConnection
|
||
}
|
||
|
||
stream, err := (*conn).UploadConfig(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Стискаємо завжди: конфіги — це текст із величезною надлишковістю,
|
||
// а канал до зонда часто вузький. Сервер розуміє обидва варіанти,
|
||
// тому вибір тут наш.
|
||
var zbuf bytes.Buffer
|
||
zw := gzip.NewWriter(&zbuf)
|
||
if _, err := zw.Write(res.Body); err != nil {
|
||
return err
|
||
}
|
||
if err := zw.Close(); err != nil {
|
||
return err
|
||
}
|
||
payload := zbuf.Bytes()
|
||
|
||
if err := stream.Send(&npv1.ConfigUpload{
|
||
Part: &npv1.ConfigUpload_Header{Header: &npv1.ConfigHeader{
|
||
JobId: job.GetJobId(),
|
||
AgentId: s.cfg.AgentID,
|
||
DeviceId: job.GetDevice().GetDeviceId(),
|
||
ConfigType: job.GetConfigType(),
|
||
CollectedAt: timestamppb.Now(),
|
||
Encoding: "gzip",
|
||
}},
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
|
||
var chunks uint32
|
||
for off := 0; off < len(payload); off += chunkSize {
|
||
end := off + chunkSize
|
||
if end > len(payload) {
|
||
end = len(payload)
|
||
}
|
||
if err := stream.Send(&npv1.ConfigUpload{
|
||
Part: &npv1.ConfigUpload_Chunk{Chunk: &npv1.ConfigChunk{
|
||
Sequence: chunks,
|
||
Data: payload[off:end],
|
||
}},
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
chunks++
|
||
}
|
||
|
||
// sha256 рахується від ОРИГІНАЛУ, до стиснення: сервер звіряє саме
|
||
// тіло конфігу, а не його пакування. Інакше зміна рівня стиснення
|
||
// виглядала б як зміна конфігу.
|
||
if err := stream.Send(&npv1.ConfigUpload{
|
||
Part: &npv1.ConfigUpload_Trailer{Trailer: &npv1.ConfigTrailer{
|
||
Success: true,
|
||
ContentSha256: res.SHA256,
|
||
SizeBytes: uint64(len(res.Body)),
|
||
LineCount: uint32(res.LineCount),
|
||
ChunkCount: chunks,
|
||
Duration: durationpb.New(res.Duration),
|
||
Transcript: res.Transcript,
|
||
}},
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
|
||
receipt, err := stream.CloseAndRecv()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !receipt.GetAccepted() {
|
||
s.log.Warn("сервер не прийняв конфіг",
|
||
"job_id", job.GetJobId(), "причина", receipt.GetError().GetMessage())
|
||
return nil
|
||
}
|
||
|
||
s.log.Info("конфіг прийнято",
|
||
"job_id", job.GetJobId(),
|
||
"змінився", !receipt.GetUnchanged(),
|
||
"commit", receipt.GetCommitSha())
|
||
return nil
|
||
}
|
||
|
||
// uploadFailure повідомляє про невдачу тим самим стрімом.
|
||
//
|
||
// Мовчати не можна: без цього невдалий збір виглядає на сервері як
|
||
// «завдання ще виконується», і наступний запуск за розкладом лише
|
||
// додасть другий такий самий. Транскрипт долучається саме тут — він
|
||
// потрібен рівно тоді, коли щось пішло не так.
|
||
func (s *Session) uploadFailure(ctx context.Context, job *npv1.ConfigJob, cause error, transcript string) {
|
||
conn := s.client.Load()
|
||
if conn == nil {
|
||
return
|
||
}
|
||
stream, err := (*conn).UploadConfig(ctx)
|
||
if err != nil {
|
||
return
|
||
}
|
||
|
||
_ = stream.Send(&npv1.ConfigUpload{
|
||
Part: &npv1.ConfigUpload_Header{Header: &npv1.ConfigHeader{
|
||
JobId: job.GetJobId(),
|
||
AgentId: s.cfg.AgentID,
|
||
DeviceId: job.GetDevice().GetDeviceId(),
|
||
ConfigType: job.GetConfigType(),
|
||
CollectedAt: timestamppb.Now(),
|
||
Encoding: "none",
|
||
}},
|
||
})
|
||
_ = stream.Send(&npv1.ConfigUpload{
|
||
Part: &npv1.ConfigUpload_Trailer{Trailer: &npv1.ConfigTrailer{
|
||
Success: false,
|
||
Error: &npv1.Error{
|
||
Code: "collect_failed",
|
||
Message: cause.Error(),
|
||
},
|
||
Transcript: transcript,
|
||
}},
|
||
})
|
||
_, _ = stream.CloseAndRecv()
|
||
}
|