Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
242 lines
11 KiB
Go
242 lines
11 KiB
Go
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 ""
|
||
}
|