Netpulse_SasS/agent/internal/ncmx/apply.go
byrsapty ed8fc831bf Дві сесії роботи: 0058–0068, розгортання однією командою, тести
Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.

ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ

  0058  подієві алерти: syslog, ncm, compliance спрацьовують у мить
        події; правило з нереалізованим джерелом більше не зберігається
        мовчки
  0059  snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
        описуються шаблоном, а не Go
  0060  відкат конфігу: план як різниця, маскування паролів із підписом
        плану, обов'язковий контрольний збір, verifying при обриві
  0061  кнопки Telegram: довге опитування, авторизація не з callback_data
  0062  аудит і архів хостів; тест на AST, що падає на ключі без назви
  0063  RLS: три ролі, окремий пул для фонових тактів
  0064  строки зберігання даних і сторінка сховища
  0065  приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
        переклад v1→v2 за RFC 3584 дає правильний OID
  0066  ескалації сповіщень
  0067  алерт про вичерпання диска
  0068  поля заливки конфігу переїхали в каталог профілів

Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.

ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ

  netpulse            установник: одна команда замість 18 змінних і
                      593 рядків інструкції
  RLS з першого запуску  нова інсталяція під політиками одразу;
                      RLS-EXISTING-INSTALL.md лишається тільки для
                      старих інсталяцій
  .forgejo + CI       раннер не зареєстрований

Ці три перевірені компіляцією й міркуванням, але не виконанням.

ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ

Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.

Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.

Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
2026-08-27 17:32:49 +03:00

242 lines
11 KiB
Go
Raw Permalink 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 ncmx
import (
"bytes"
"context"
"fmt"
"regexp"
"strings"
"time"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
)
// Заливка конфігу на пристрій — єдине місце, де зонд ПИШЕ на залізо.
//
// Транспорт, вхід, пошук запрошення тут ті самі, що в збору конфігу й
// виконанні команд: Dial, WaitPrompt, enable, CLI.Run. Другого шляху до
// пристрою в зонда немає й не має бути — сесія, відкрита іншим кодом,
// означала б другий набір помилок входу, другий спосіб зіпсувати
// стенограму й другу відповідь на питання «чому не під'єднались».
//
// Відрізняється не механіка, а поводження з помилкою.
//
// Збір конфігу, який упав, — це відсутність результату: нічого не
// сталось, спробуємо ще раз уночі. Заливка, яка впала на третій команді
// з двадцяти, — це ДВІ виконані команди на живому вузлі. Тому:
//
// * зупиняємось на першій же помилці (сервер шле continue_on_error =
// false), бо доливати рядки в конфіг, який уже пішов не тим шляхом,
// означає отримати стан, якого не описує жоден із двох конфігів;
// * звіт віддається ЗАВЖДИ, навіть якщо обірвалось усе, — у ньому
// видно, що саме встигло виконатись, а це половина відповіді на
// питання, у якому стані зараз вузол;
// * команда збереження виконується лише після повного успіху:
// зберегти напівзалитий конфіг означає зробити його постійним.
//
// ПАРОЛЬ У СТЕНОГРАМУ НЕ ПОТРАПЛЯЄ. Правило проєкту лишається чинним і
// тут: у буфер стенограми пишуться лише байти, які надіслав САМ
// пристрій (див. session.go і transport.go). Але заливка додає ризик,
// якого не було в збору: пристрій відлунює те, що ми йому шлемо, а
// шлемо ми рядки конфігу — зокрема з паролями. Ті байти прийшли від
// пристрою, тож у стенограму вони лягають законно, і прибрати їх тут
// нічим: зонд не знає, які саме рядки в цієї родини секретні. Тому
// маскування зроблено на сервері, за redact_patterns профілю, до
// запису в базу (див. store.FinishApply).
// ApplyResult — те, що зонд повідомляє про заливку.
type ApplyResult struct {
Outcomes []*npv1.CommandOutcome
Committed bool
Transcript string
Duration time.Duration
// Err — причина, з якої послідовність не дійшла до кінця.
Err error
}
// applyRejects — як залізо каже «я не зрозумів цей рядок».
//
// Окремий перелік, а не спільний із cliRejects зі збору, і різниця
// принципова. Там перелік застосовується до КОРОТКОГО виводу й слугує
// відповіді на питання «це взагалі конфіг?»; тут — до відповіді на
// кожен рядок, і ціна помилки інша в обидва боки.
//
// Пропустити відмову — найгірше: заливка піде далі, лишивши по собі
// напівзастосований конфіг, який виглядає застосованим. Тому сюди
// додано те, чого в збірному переліку немає: відмови прав доступу й
// скарги на контекст («command is not supported in current mode»).
//
// Прийняти за відмову звичайний рядок — теж погано, але дешевше: ми
// зупинимось раніше, ніж треба, і людина побачить, на чому саме, разом
// із самим виводом. Тому зразки прив'язані до початку рядка або до
// характерних маркерів («%», «Error:»), а не шукаються будь-де.
var applyRejects = regexp.MustCompile(`(?im)^\s*(%|\^|Error:|ERROR:)|` +
`invalid input|unknown command|invalid command|incomplete command|` +
`syntax error|command not found|next possible completions|` +
`not supported|permission denied|access denied|authorization failed|` +
`fail(ed)? to (set|apply|create|delete)`)
// Apply виконує завдання заливки конфігу.
//
// Помилку в ApplyResult.Err ставить і тоді, коли до пристрою взагалі не
// дійшли: звіт із порожнім переліком команд і зрозумілою причиною —
// теж відповідь, і саме вона потрібна серверу, щоб не вирішити, що
// зонд просто зник.
func Apply(ctx context.Context, job *npv1.ConfigApplyJob) ApplyResult {
start := time.Now()
res := ApplyResult{Outcomes: make([]*npv1.CommandOutcome, 0, len(job.GetCommands()))}
if len(job.GetCommands()) == 0 {
res.Err = fmt.Errorf("завдання заливки без жодної команди")
return res
}
promptRe, err := compilePrompt(job.GetPromptRegex())
if err != nil {
res.Err = err
return res
}
timeout := job.GetTimeout().AsDuration()
if timeout <= 0 {
timeout = 10 * time.Minute
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// Стенограма пишеться завжди, без прапорця в завданні. Заливка —
// незворотна дія: питання «а що саме пристрій на це відповів»
// ставлять уже після того, як усе сталося, і другого шансу зняти
// ту саму сесію не буде.
transcript := &bytes.Buffer{}
defer func() {
res.Transcript = transcript.String()
res.Duration = time.Since(start)
}()
cred := job.GetCredential()
conn, err := Dial(ctx, transportName(job.GetTransport()),
job.GetDevice().GetAddress(), int(cred.GetPort()),
cred.GetUsername(), password(cred), privateKey(cred),
connectTimeout(timeout), transcript)
if err != nil {
res.Err = fmt.Errorf("підключення: %w", err)
return res
}
defer conn.Close()
cli := NewCLI(conn, Options{PromptRe: promptRe, Transcript: transcript})
if err := cli.WaitPrompt(ctx); err != nil {
res.Err = fmt.Errorf("привітання пристрою: %w", err)
return res
}
if job.GetEnableRequired() {
if err := enable(ctx, cli, cred.GetEnablePassword()); err != nil {
res.Err = err
return res
}
}
res.Outcomes, res.Committed, res.Err = applyLoop(ctx, cli, job)
return res
}
// applyLoop проганяє рядки конфігу по черзі.
//
// Окремо від Apply, щоб перевірятись без мережі: усе, що варте
// перевірки, відбувається саме тут, а Dial із живим сокетом у тест не
// затягнеш. Так само зроблено для масового виконання команд
// (runCommandLoop) — і з тієї ж причини.
func applyLoop(ctx context.Context, cli *CLI, job *npv1.ConfigApplyJob) (
[]*npv1.CommandOutcome, bool, error) {
outcomes := make([]*npv1.CommandOutcome, 0, len(job.GetCommands())+1)
for i, cmd := range job.GetCommands() {
out, err := cli.Run(ctx, cmd)
oc := &npv1.CommandOutcome{
Index: uint32(i),
Command: cmd,
Output: strings.TrimRight(out, "\n"),
Success: true,
}
if err != nil {
// Не дочекались запрошення або обірвався канал. Найгірший
// різновид: ми не знаємо навіть, чи прочитав пристрій цей
// рядок. Далі не йдемо в жодному разі.
oc.Success = false
oc.ErrorLine = err.Error()
outcomes = append(outcomes, oc)
return outcomes, false, fmt.Errorf("рядок %d (%q): %w", i+1, cmd, err)
}
if line := rejectLine(out); line != "" {
oc.Success = false
oc.ErrorLine = line
outcomes = append(outcomes, oc)
if !job.GetContinueOnError() {
return outcomes, false, fmt.Errorf("пристрій відхилив рядок %d (%q): %s",
i+1, cmd, line)
}
continue
}
outcomes = append(outcomes, oc)
}
// Збереження — лише після повного успіху.
//
// Незбережений напівзалитий конфіг має одну важливу властивість:
// його прибирає перезавантаження вузла. Зберегти його означає
// відібрати в людини цю останню можливість — і зробити помилку
// постійною.
commit := strings.TrimSpace(job.GetCommitCommand())
if commit == "" {
return outcomes, false, nil
}
out, err := cli.Run(ctx, commit)
oc := &npv1.CommandOutcome{
Index: uint32(len(outcomes)),
Command: commit,
Output: strings.TrimRight(out, "\n"),
Success: err == nil && rejectLine(out) == "",
}
switch {
case err != nil:
oc.ErrorLine = err.Error()
case !oc.Success:
oc.ErrorLine = rejectLine(out)
}
outcomes = append(outcomes, oc)
if !oc.Success {
// Конфіг застосований, але не збережений. Це НЕ повний провал і
// не повний успіх: до перезавантаження пристрій працює за новим
// конфігом, після — за старим. Такий стан має бути видно
// окремо, тому committed лишається false, а причина йде в
// помилку.
return outcomes, false, fmt.Errorf("конфіг залито, але зберегти не вдалося (%q): %s",
commit, oc.ErrorLine)
}
return outcomes, true, nil
}
// rejectLine повертає перший рядок виводу, схожий на відмову.
//
// Саме рядок, а не «так/ні»: людині в звіті потрібно бачити, ЧИМ
// пристрій відповів. «Пристрій відхилив рядок 7» без його власних слів
// — це запрошення відкривати стенограму щоразу.
func rejectLine(out string) string {
if strings.TrimSpace(out) == "" {
return ""
}
for _, ln := range strings.Split(out, "\n") {
if applyRejects.MatchString(ln) {
return strings.TrimSpace(ln)
}
}
return ""
}