Netpulse_SasS/agent/internal/session/config_jobs.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

180 lines
6 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 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()
}