Netpulse_SasS/server/internal/store/ncm_rollback.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

1229 lines
48 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 store
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
"github.com/netpulse/netpulse/server/internal/crypto"
"google.golang.org/protobuf/types/known/durationpb"
)
// Відкат конфігурації: намір → погодження → заливка → контрольний збір.
//
// Шлях до пристрою тут той самий, що в збору конфігів і масових команд:
// сервер кладе рядок у базу, диспетчер колектора віддає його живій
// сесії зонда, зонд відкриває CLI. Нового транспорту не з'явилось —
// у .proto ConfigApplyJob/ConfigApplyResult лежали від початку й лише
// чекали коду.
//
// Що тут НЕ так, як у масових командах, і чому.
//
// Команди набирає людина — і система про них не знає нічого. Тут
// команди будує система з двох збережених версій конфігу, і саме тому
// вона зобов'язана довести, що зробила те, що обіцяла: після заливки
// йде контрольний збір, план будується з нього наново, і «нуль команд»
// — єдиний доказ, що відкат відбувся. Вивід CLI доказом не є: пристрій
// відповідає рядком тексту, і мовчання означає «прочитав», а не
// «застосував».
//
// Друга відмінність — погодження. Масову команду запускає той, хто має
// право; відкат за політикою вимагає другої людини. Знімок політики
// лежить у самому намірі: вимкнення вимоги заднім числом не має
// легалізувати те, що вже висить у черзі.
var (
// ErrRollbackStale — план розійшовся з погодженим.
ErrRollbackStale = errors.New("конфіг на пристрої змінився після погодження — відкат треба переглянути заново")
// ErrRollbackSelfApprove — той самий автор.
ErrRollbackSelfApprove = errors.New("відкат має погодити інша людина")
// ErrRollbackState — дія не підходить до поточного стану.
ErrRollbackState = errors.New("відкат уже не в тому стані")
)
// applyTimeout — стеля на всю сесію заливки.
//
// Більша за збір конфігу (5 хв) навмисно: заливка — це десятки команд
// по черзі, кожна з очікуванням запрошення, а частина платформ думає
// над `write memory` довше, ніж над усім іншим разом.
const applyTimeout = 10 * time.Minute
// verifyGrace — скільки чекати на результат заливки, перш ніж вважати
// зонд зниклим.
//
// Свідомо більша за applyTimeout: зонд, який ще пише на пристрій, не
// має вважатись мертвим, поки не вичерпав власний бюджет.
const verifyGrace = 15 * time.Minute
// RollbackPolicySettings — чи вимагає відкат другої людини.
type RollbackPolicySettings struct {
RequireApproval bool `json:"require_approval"`
AllowSelfApprove bool `json:"allow_self_approve"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
UpdatedBy string `json:"updated_by,omitempty"`
}
// ApplyOutcome — результат однієї команди заливки.
type ApplyOutcome struct {
Index int `json:"index"`
Command string `json:"command"`
Output string `json:"output"`
Success bool `json:"success"`
ErrorLine string `json:"error_line,omitempty"`
}
// RollbackPreview — те, що людина бачить ДО створення наміру.
type RollbackPreview struct {
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
TargetConfigID string `json:"target_config_id"`
TargetCollected time.Time `json:"target_collected_at"`
BaseConfigID string `json:"base_config_id"`
BaseCollected time.Time `json:"base_collected_at"`
ProfileName string `json:"profile_name,omitempty"`
Supported bool `json:"supported"`
// Note пояснює, ЧОМУ відкат недоступний або обмежений.
Note string `json:"note,omitempty"`
// Commands — уже замасковані: сюди дивиться людина, а не пристрій.
Commands []string `json:"commands"`
Manual []string `json:"manual"`
CommitCommand string `json:"commit_command,omitempty"`
Added int `json:"added"`
Removed int `json:"removed"`
RequiresApproval bool `json:"requires_approval"`
AgentOnline bool `json:"agent_online"`
// PlanHash підписує СПРАВЖНІЙ план. Клієнт повертає його при
// створенні наміру: так «створити відкат» означає «створити той
// відкат, який я щойно бачив», а не «перебудувати план заново на
// момент натискання».
PlanHash string `json:"plan_hash"`
}
// RollbackInput — створення наміру.
type RollbackInput struct {
DeviceID string `json:"device_id"`
TargetConfigID string `json:"target_config_id"`
Reason string `json:"reason"`
// PlanHash — підпис того, що людина бачила.
PlanHash string `json:"plan_hash"`
// AcceptManual — людина бачила перелік «прибрати вручну» й свідомо
// погодилась, що ці рядки лишаться на пристрої.
AcceptManual bool `json:"accept_manual"`
}
// Rollback — намір разом із усім, що з ним сталося.
type Rollback struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
Status string `json:"status"`
TargetConfigID string `json:"target_config_id"`
TargetCollected *time.Time `json:"target_collected_at,omitempty"`
BaseConfigID string `json:"base_config_id,omitempty"`
// Commands — замаскований план, той самий, що бачила людина.
Commands []string `json:"commands"`
Manual []string `json:"manual"`
CommitCommand string `json:"commit_command,omitempty"`
Reason string `json:"reason,omitempty"`
RequiresApproval bool `json:"requires_approval"`
RequestedBy string `json:"requested_by,omitempty"`
ApprovedBy string `json:"approved_by,omitempty"`
RejectedBy string `json:"rejected_by,omitempty"`
DecisionNote string `json:"decision_note,omitempty"`
CreatedAt time.Time `json:"created_at"`
ApprovedAt *time.Time `json:"approved_at,omitempty"`
RejectedAt *time.Time `json:"rejected_at,omitempty"`
SentAt *time.Time `json:"sent_at,omitempty"`
AppliedAt *time.Time `json:"applied_at,omitempty"`
Outcomes []ApplyOutcome `json:"outcomes"`
Committed bool `json:"committed"`
Error string `json:"error,omitempty"`
// ResultLog — стенограма сесії, уже замаскована. Віддається лише
// за окремим проханням: вона важить сотні кілобайт.
ResultLog string `json:"result_log,omitempty"`
VerifyConfigID string `json:"verify_config_id,omitempty"`
VerifyRemaining *int `json:"verify_remaining,omitempty"`
}
// ---------------------------------------------------------------------
// Політика
// ---------------------------------------------------------------------
// GetRollbackPolicy повертає політику кабінету.
//
// Відсутній рядок — не «політики немає», а «діють типові значення»:
// погодження вимагається, самопогодження заборонене. Саме тому рядок не
// створюється при читанні: кабінет, який жодного разу не відкривав
// налаштування, має бути захищений так само, як той, що їх зберіг.
func (s *Store) GetRollbackPolicy(ctx context.Context, tenantID string) (RollbackPolicySettings, error) {
out := RollbackPolicySettings{RequireApproval: true}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var (
at time.Time
user string
)
err := tx.QueryRow(ctx, `
SELECT p.require_approval, p.allow_self_approve, p.updated_at,
COALESCE(u.username, '')
FROM ncm.rollback_policy p
LEFT JOIN core.users u ON u.id = p.updated_by
WHERE p.tenant_id = $1
`, tenantID).Scan(&out.RequireApproval, &out.AllowSelfApprove, &at, &user)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
if err != nil {
return err
}
out.UpdatedAt = &at
out.UpdatedBy = user
return nil
})
return out, err
}
func (s *Store) SetRollbackPolicy(ctx context.Context, tenantID, userID string,
in RollbackPolicySettings) (RollbackPolicySettings, error) {
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
INSERT INTO ncm.rollback_policy
(tenant_id, require_approval, allow_self_approve, updated_at, updated_by)
VALUES ($1, $2, $3, now(), $4)
ON CONFLICT (tenant_id) DO UPDATE SET
require_approval = EXCLUDED.require_approval,
allow_self_approve = EXCLUDED.allow_self_approve,
updated_at = now(),
updated_by = EXCLUDED.updated_by
`, tenantID, in.RequireApproval, in.AllowSelfApprove, nullUUID(userID))
return err
})
if err != nil {
return RollbackPolicySettings{}, err
}
return s.GetRollbackPolicy(ctx, tenantID)
}
// ---------------------------------------------------------------------
// Профіль заливки
// ---------------------------------------------------------------------
// applyProfileOf — як заливати конфіг на цей хост.
//
// Окремим запитом поверх resolveProfile, а не розширенням його SELECT-ів:
// підбір профілю обслуговує збір конфігів у трьох місцях, і додавати
// туди шість колонок заради відкату означало б ризикнути роботою
// бекапів заради функції, яка ще не існує. Ціна — один запит за
// первинним ключем.
func (s *Store) applyProfileOf(ctx context.Context, tenantID, deviceID string) (ApplyProfile, error) {
var out ApplyProfile
base, err := s.resolveProfile(ctx, tenantID, deviceID, s.devicePolicyProfile(ctx, tenantID, deviceID))
if err != nil {
return out, err
}
var enter, exitCmds, scrub, redact string
var commit, negate, note *string
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
SELECT key, name, apply_supported,
apply_enter::text, apply_exit::text,
apply_commit, apply_negate, apply_block_exit, apply_note,
scrub_patterns::text, redact_patterns::text
FROM ncm.profiles WHERE id = $1
`, base.ID).Scan(&out.Key, &out.Name, &out.Supported,
&enter, &exitCmds, &commit, &negate, &out.BlockExit, &note,
&scrub, &redact)
})
if errors.Is(err, pgx.ErrNoRows) {
return out, ErrNoProfile
}
if err != nil {
return out, err
}
out.Enter = jsonStrings(enter)
out.Exit = jsonStrings(exitCmds)
out.ScrubPatterns = jsonStrings(scrub)
out.RedactPatterns = jsonStrings(redact)
out.Commit = derefStr(commit)
out.Negate = derefStr(negate)
out.Note = derefStr(note)
out.PromptRegex = base.PromptRegex
out.EnableRequired = base.EnableRequired
out.Transport = base.Transport
return out, nil
}
// devicePolicyProfile — профіль, закріплений за хостом (може бути порожній).
//
// Окремим методом заради читабельності виклику вище: resolveProfile
// приймає «явно заданий» третім аргументом, і вбудований у виклик
// підзапит робив би рядок нечитабельним.
func (s *Store) devicePolicyProfile(ctx context.Context, tenantID, deviceID string) *string {
var id *string
_ = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
SELECT profile_id::text FROM ncm.device_policies
WHERE device_id = $1 AND tenant_id = $2
`, deviceID, tenantID).Scan(&id)
})
return id
}
// ---------------------------------------------------------------------
// Намір
// ---------------------------------------------------------------------
// PrepareRollback будує план, нічого не змінюючи.
//
// Окремий крок перед створенням наміру, і не заради зручності. План —
// це і є те, що людина погоджує: без нього підтвердження звучало б як
// «відкотити на версію від 12 березня», тобто як обіцянка без змісту.
// Тут же з'ясовується й найчастіша відмова — «для профілю цього хоста
// відкат не налаштований», — і з'ясовується ДО того, як хтось почав
// збирати підписи.
func (s *Store) PrepareRollback(ctx context.Context, tenantID, deviceID, targetConfigID string,
ring *crypto.Keyring) (RollbackPreview, error) {
var out RollbackPreview
out.DeviceID = deviceID
out.TargetConfigID = targetConfigID
var agentID string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
SELECT d.name, COALESCE(d.agent_id::text, '')
FROM inv.devices d
WHERE d.id = $1 AND d.tenant_id = $2 AND d.deleted_at IS NULL
`, deviceID, tenantID).Scan(&out.DeviceName, &agentID)
})
if errors.Is(err, pgx.ErrNoRows) {
return out, ErrNotFound
}
if err != nil {
return out, err
}
pol, err := s.GetRollbackPolicy(ctx, tenantID)
if err != nil {
return out, err
}
out.RequiresApproval = pol.RequireApproval
prof, perr := s.applyProfileOf(ctx, tenantID, deviceID)
out.ProfileName = prof.Name
out.Note = prof.Note
if perr != nil {
// Хост без профілю — це не поломка відкату, а стан хоста, і
// сказати про нього треба тим самим текстом, яким про нього
// каже збір конфігів.
if errors.Is(perr, ErrNoProfile) {
out.Note = perr.Error()
return out, nil
}
return out, perr
}
out.Supported = prof.Supported
if !prof.Supported {
return out, nil
}
targetBody, targetVer, err := s.ConfigBody(ctx, tenantID, targetConfigID, ring)
if err != nil {
return out, err
}
if targetVer.DeviceID != deviceID {
return out, fmt.Errorf("%w: обрана версія належить іншому хосту", ErrInvalid)
}
out.TargetCollected = targetVer.CollectedAt
baseID, err := s.latestConfigID(ctx, tenantID, deviceID, targetVer.ConfigType)
if err != nil {
return out, err
}
if baseID == "" {
return out, fmt.Errorf("%w: у хоста немає жодної зібраної версії", ErrInvalid)
}
baseBody, baseVer, err := s.ConfigBody(ctx, tenantID, baseID, ring)
if err != nil {
return out, err
}
out.BaseConfigID = baseID
out.BaseCollected = baseVer.CollectedAt
plan, err := BuildRollbackPlan(baseBody, targetBody, prof)
if err != nil {
return out, err
}
out.Commands = redactLines(prof.RedactPatterns, plan.Commands)
out.Manual = redactLines(prof.RedactPatterns, plan.Manual)
out.CommitCommand = plan.CommitCommand
out.Added = plan.Added
out.Removed = plan.Removed
out.PlanHash = planHash(plan)
out.AgentOnline = agentID != ""
return out, nil
}
// planHash підписує справжній план.
//
// Роздільник із символу, якого в командах бути не може: інакше
// ["a","b"] і ["a b"] дали б однаковий підпис, і зміна структури плану
// пройшла б непоміченою.
func planHash(p RollbackPlan) string {
h := sha256.New()
for _, c := range p.Commands {
h.Write([]byte(c))
h.Write([]byte{0})
}
h.Write([]byte{1})
for _, m := range p.Manual {
h.Write([]byte(m))
h.Write([]byte{0})
}
h.Write([]byte{2})
h.Write([]byte(p.CommitCommand))
return hex.EncodeToString(h.Sum(nil))
}
func (s *Store) latestConfigID(ctx context.Context, tenantID, deviceID, configType string) (string, error) {
var id string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
SELECT id::text FROM ncm.configs
WHERE tenant_id = $1 AND device_id = $2 AND config_type = $3
ORDER BY collected_at DESC LIMIT 1
`, tenantID, deviceID, configType).Scan(&id)
})
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
return id, err
}
// CreateRollback заводить намір відкату.
//
// План будується ще раз, на сервері, і звіряється підписом із тим, що
// бачив клієнт. Та сама думка, що й у масових командах: тіло запиту
// складає браузер, а між переглядом і натисканням минає час — за який
// нічний бекап цілком міг принести нову версію конфігу. Розбіжність
// підписів означає, що людина погоджує вже не те, що бачила, і єдина
// правильна реакція — показати різницю заново.
func (s *Store) CreateRollback(ctx context.Context, tenantID, userID string,
in RollbackInput, ring *crypto.Keyring) (Rollback, error) {
var out Rollback
prof, err := s.applyProfileOf(ctx, tenantID, in.DeviceID)
if err != nil {
return out, err
}
if !prof.Supported {
return out, ErrRollbackNotSupported
}
prev, err := s.PrepareRollback(ctx, tenantID, in.DeviceID, in.TargetConfigID, ring)
if err != nil {
return out, err
}
if in.PlanHash != "" && in.PlanHash != prev.PlanHash {
return out, ErrRollbackStale
}
if len(prev.Manual) > 0 && !in.AcceptManual {
return out, fmt.Errorf("%w: %d рядків неможливо прибрати автоматично — "+
"їх треба підтвердити окремо", ErrInvalid, len(prev.Manual))
}
pol, err := s.GetRollbackPolicy(ctx, tenantID)
if err != nil {
return out, err
}
// Стан при створенні. Без вимоги погодження намір одразу готовий до
// виконання — але це саме рішення політики, а не «зручний типовий
// шлях»: 'approved' без погодження має бути видно в переліку так
// само явно, як погоджений.
status := "awaiting_approval"
if !pol.RequireApproval {
status = "approved"
}
cmdJSON, err := json.Marshal(prev.Commands)
if err != nil {
return out, err
}
manualJSON, err := json.Marshal(prev.Manual)
if err != nil {
return out, err
}
hashBytes, err := hex.DecodeString(prev.PlanHash)
if err != nil {
return out, err
}
var id string
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
INSERT INTO ncm.rollbacks
(tenant_id, device_id, agent_id, target_config_id, base_config_id,
status, commands, manual_lines, commit_command, plan_hash,
reason, requires_approval, requested_by,
approved_by, approved_at)
SELECT $1, d.id, d.agent_id, $3::uuid, $4,
$5::ncm.rollback_status, $6::jsonb, $7::jsonb, NULLIF($8,''), $9,
NULLIF($10,''), $11, $12,
CASE WHEN $11 THEN NULL ELSE $12::uuid END,
CASE WHEN $11 THEN NULL ELSE now() END
FROM inv.devices d
WHERE d.id = $2 AND d.tenant_id = $1 AND d.deleted_at IS NULL
RETURNING id::text
`, tenantID, in.DeviceID, in.TargetConfigID, nullUUID(prev.BaseConfigID),
status, string(cmdJSON), string(manualJSON), prev.CommitCommand, hashBytes,
strings.TrimSpace(in.Reason), pol.RequireApproval, nullUUID(userID)).Scan(&id)
})
if errors.Is(err, pgx.ErrNoRows) {
return out, ErrNotFound
}
if err != nil {
return out, err
}
return s.GetRollback(ctx, tenantID, id, false)
}
// ApproveRollback — та сама друга людина.
//
// Перевірка «не той самий автор» стоїть тут, а не в обробнику HTTP,
// бо це властивість наміру, а не інтерфейсу: намір, погоджений своїм же
// автором, не має існувати незалежно від того, яким шляхом його
// погодили — вебом, токеном інтеграції чи майбутнім ботом.
func (s *Store) ApproveRollback(ctx context.Context, tenantID, id, userID, note string) (Rollback, error) {
pol, err := s.GetRollbackPolicy(ctx, tenantID)
if err != nil {
return Rollback{}, err
}
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var (
status string
requester *string
)
if err := tx.QueryRow(ctx, `
SELECT status::text, requested_by::text
FROM ncm.rollbacks WHERE id = $1 AND tenant_id = $2
FOR UPDATE
`, id, tenantID).Scan(&status, &requester); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
}
if status != "awaiting_approval" {
return fmt.Errorf("%w: він у стані %q", ErrRollbackState, status)
}
if !pol.AllowSelfApprove && requester != nil && *requester == userID && userID != "" {
return ErrRollbackSelfApprove
}
_, err := tx.Exec(ctx, `
UPDATE ncm.rollbacks
SET status = 'approved', approved_by = $3, approved_at = now(),
decision_note = NULLIF($4,'')
WHERE id = $1 AND tenant_id = $2
`, id, tenantID, nullUUID(userID), strings.TrimSpace(note))
return err
})
if err != nil {
return Rollback{}, err
}
return s.GetRollback(ctx, tenantID, id, false)
}
// RejectRollback закриває намір без виконання.
//
// Відмова доступна не лише з 'awaiting_approval', а й з 'approved':
// між погодженням і відправкою минають секунди, але за них цілком може
// з'ясуватись, що відкочувати вже не треба. Після 'applying' відмова
// безглузда — на пристрої вже пишуть.
func (s *Store) RejectRollback(ctx context.Context, tenantID, id, userID, note string) (Rollback, error) {
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
UPDATE ncm.rollbacks
SET status = 'rejected', rejected_by = $3, rejected_at = now(),
decision_note = NULLIF($4,'')
WHERE id = $1 AND tenant_id = $2
AND status IN ('draft','awaiting_approval','approved')
`, id, tenantID, nullUUID(userID), strings.TrimSpace(note))
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrRollbackState
}
return nil
})
if err != nil {
return Rollback{}, err
}
return s.GetRollback(ctx, tenantID, id, false)
}
// ---------------------------------------------------------------------
// Читання
// ---------------------------------------------------------------------
const rollbackCols = `
r.id::text, r.device_id::text, COALESCE(d.name,''), r.status::text,
r.target_config_id::text, c.collected_at, COALESCE(r.base_config_id::text,''),
r.commands::text, r.manual_lines::text, COALESCE(r.commit_command,''),
COALESCE(r.reason,''), r.requires_approval,
COALESCE(ru.username,''), COALESCE(au.username,''), COALESCE(rj.username,''),
COALESCE(r.decision_note,''),
r.created_at, r.approved_at, r.rejected_at, r.sent_at, r.applied_at,
r.outcomes::text, r.committed, COALESCE(r.error,''),
COALESCE(r.verify_config_id::text,''), r.verify_remaining`
const rollbackFrom = `
FROM ncm.rollbacks r
LEFT JOIN inv.devices d ON d.id = r.device_id
LEFT JOIN ncm.configs c ON c.id = r.target_config_id
LEFT JOIN core.users ru ON ru.id = r.requested_by
LEFT JOIN core.users au ON au.id = r.approved_by
LEFT JOIN core.users rj ON rj.id = r.rejected_by`
func scanRollback(rows pgx.Row) (Rollback, error) {
var (
r Rollback
cmdRaw, manualRaw string
outcomesRaw string
targetAt *time.Time
verifyRemaining *int
)
err := rows.Scan(&r.ID, &r.DeviceID, &r.DeviceName, &r.Status,
&r.TargetConfigID, &targetAt, &r.BaseConfigID,
&cmdRaw, &manualRaw, &r.CommitCommand,
&r.Reason, &r.RequiresApproval,
&r.RequestedBy, &r.ApprovedBy, &r.RejectedBy, &r.DecisionNote,
&r.CreatedAt, &r.ApprovedAt, &r.RejectedAt, &r.SentAt, &r.AppliedAt,
&outcomesRaw, &r.Committed, &r.Error,
&r.VerifyConfigID, &verifyRemaining)
if err != nil {
return r, err
}
r.TargetCollected = targetAt
r.VerifyRemaining = verifyRemaining
r.Commands = jsonStrings(cmdRaw)
r.Manual = jsonStrings(manualRaw)
r.Outcomes = []ApplyOutcome{}
if outcomesRaw != "" && outcomesRaw != "null" {
_ = json.Unmarshal([]byte(outcomesRaw), &r.Outcomes)
}
return r, nil
}
// ListRollbacks — історія відкатів; порожній deviceID означає «усі».
func (s *Store) ListRollbacks(ctx context.Context, tenantID, deviceID string, limit int) ([]Rollback, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
out := []Rollback{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `SELECT`+rollbackCols+rollbackFrom+`
WHERE r.tenant_id = $1 AND ($2 = '' OR r.device_id::text = $2)
ORDER BY r.created_at DESC
LIMIT $3`, tenantID, deviceID, limit)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
r, err := scanRollback(rows)
if err != nil {
return err
}
out = append(out, r)
}
return rows.Err()
})
return out, err
}
// GetRollback — один намір; стенограма лише за проханням.
func (s *Store) GetRollback(ctx context.Context, tenantID, id string, withLog bool) (Rollback, error) {
var out Rollback
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
row := tx.QueryRow(ctx, `SELECT`+rollbackCols+rollbackFrom+`
WHERE r.tenant_id = $1 AND r.id = $2`, tenantID, id)
r, err := scanRollback(row)
if err != nil {
return err
}
out = r
if !withLog {
return nil
}
var log string
if err := tx.QueryRow(ctx, `
SELECT COALESCE(result_log,'') FROM ncm.rollbacks
WHERE id = $1 AND tenant_id = $2
`, id, tenantID).Scan(&log); err != nil {
return err
}
out.ResultLog = log
return nil
})
if errors.Is(err, pgx.ErrNoRows) {
return out, ErrNotFound
}
return out, err
}
// ---------------------------------------------------------------------
// Виконання
// ---------------------------------------------------------------------
// PendingRollback — погоджений намір, готовий поїхати зонду.
type PendingRollback struct {
RollbackID string
TenantID string
AgentID string
DeviceName string
Job *npv1.ConfigApplyJob
}
// ClaimRollbacks забирає погоджені наміри для зондів на зв'язку.
//
// Забирає, а не читає: рядок одразу переходить у 'applying'. Два
// екземпляри колектора за балансувальником інакше надіслали б один
// відкат двічі — тобто залили б конфіг на пристрій двома паралельними
// сесіями. Для збору конфігу подвійна відправка коштувала б зайвого
// трафіку; тут вона коштувала б вузла.
//
// Пул bg, як і в решти запитів-шукачів черг: диспетчер один на всю
// інсталяцію й наперед не знає кабінету. Далі, коли кабінет уже
// відомий, усе йде через InTenantTx — зокрема читання тіл конфігів, з
// яких будується план.
func (s *Store) ClaimRollbacks(ctx context.Context, onlineAgents []string, limit int,
ring *crypto.Keyring) ([]PendingRollback, error) {
if len(onlineAgents) == 0 {
return nil, nil
}
if limit <= 0 {
limit = 4
}
rows, err := s.bg.Query(ctx, `
UPDATE ncm.rollbacks r
SET status = 'applying', sent_at = now()
WHERE r.id IN (
SELECT id FROM ncm.rollbacks
WHERE status = 'approved' AND agent_id = ANY($1::uuid[])
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT $2
)
RETURNING r.id::text, r.tenant_id::text, r.agent_id::text, r.device_id::text
`, onlineAgents, limit)
if err != nil {
return nil, err
}
type claimed struct{ id, tenantID, agentID, deviceID string }
var list []claimed
for rows.Next() {
var c claimed
if err := rows.Scan(&c.id, &c.tenantID, &c.agentID, &c.deviceID); err != nil {
rows.Close()
return nil, err
}
list = append(list, c)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]PendingRollback, 0, len(list))
for _, c := range list {
job, name, err := s.buildApplyJob(ctx, c.tenantID, c.id, c.deviceID, ring)
if err != nil {
// Намір, який неможливо зібрати, падає зараз і з
// поясненням. Контрольного збору тут не потрібно: на
// пристрій ще нічого не пішло.
_ = s.FailRollback(ctx, c.id, err.Error())
continue
}
out = append(out, PendingRollback{
RollbackID: c.id, TenantID: c.tenantID, AgentID: c.agentID,
DeviceName: name, Job: job,
})
}
return out, nil
}
// buildApplyJob перебудовує план і звіряє його з погодженим.
//
// Саме перебудовує, а не читає збережений: у базі лежить ЗАМАСКОВАНИЙ
// план (у ncm.rollbacks.commands замість паролів стоять зірочки), і
// відправити його на пристрій означало б записати туди зірочки.
// Справжні рядки живуть лише в зашифрованих тілах версій.
//
// Побічний наслідок виявився головним запобіжником. Перебудова йде від
// ПОТОЧНОГО стану хоста, тож якщо між погодженням і відправкою конфіг
// на пристрої змінився — план вийде інший, підпис не збіжиться, і
// відкат не поїде. Тобто «погодили одне, а виконали інше» стає
// неможливим не за домовленістю, а механічно.
func (s *Store) buildApplyJob(ctx context.Context, tenantID, rollbackID, deviceID string,
ring *crypto.Keyring) (*npv1.ConfigApplyJob, string, error) {
var (
devName, address string
targetConfigID string
wantHash []byte
credID *string
)
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
SELECT d.name, COALESCE(host(d.address),''),
r.target_config_id::text, r.plan_hash, p.credential_id::text
FROM ncm.rollbacks r
JOIN inv.devices d ON d.id = r.device_id AND d.deleted_at IS NULL
LEFT JOIN ncm.device_policies p ON p.device_id = d.id
WHERE r.id = $1 AND r.tenant_id = $2
`, rollbackID, tenantID).Scan(&devName, &address, &targetConfigID, &wantHash, &credID)
})
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", ErrNotFound
}
if err != nil {
return nil, "", err
}
if address == "" {
return nil, devName, fmt.Errorf("у хоста %s немає адреси", devName)
}
prof, err := s.applyProfileOf(ctx, tenantID, deviceID)
if err != nil {
return nil, devName, err
}
if !prof.Supported {
return nil, devName, ErrRollbackNotSupported
}
targetBody, targetVer, err := s.ConfigBody(ctx, tenantID, targetConfigID, ring)
if err != nil {
return nil, devName, err
}
baseID, err := s.latestConfigID(ctx, tenantID, deviceID, targetVer.ConfigType)
if err != nil {
return nil, devName, err
}
if baseID == "" {
return nil, devName, fmt.Errorf("у хоста немає зібраної версії для порівняння")
}
baseBody, _, err := s.ConfigBody(ctx, tenantID, baseID, ring)
if err != nil {
return nil, devName, err
}
plan, err := BuildRollbackPlan(baseBody, targetBody, prof)
if err != nil {
return nil, devName, err
}
if got := planHash(plan); len(wantHash) > 0 && got != hex.EncodeToString(wantHash) {
return nil, devName, ErrRollbackStale
}
if len(plan.Commands) == 0 {
return nil, devName, ErrRollbackNothing
}
cred, err := s.resolveNcmCredential(ctx, tenantID, deviceID, credID, ring)
if err != nil {
return nil, devName, err
}
transport, port := jobTransport(prof.Transport, cred)
if cred != nil {
cred.Port = port
}
return &npv1.ConfigApplyJob{
RollbackId: rollbackID,
Device: &npv1.DeviceTarget{
DeviceId: deviceID,
Name: devName,
Address: address,
},
Credential: cred,
Transport: transport,
Commands: plan.Commands,
PromptRegex: prof.PromptRegex,
EnableRequired: prof.EnableRequired,
CommitCommand: plan.CommitCommand,
// Зупинка на першій помилці. Продовжувати означало б доливати
// рядки в конфіг, який уже пішов не тим шляхом, — і мати замість
// «зупинились на третій команді» щось, чого не описує жоден
// із двох конфігів.
ContinueOnError: false,
Timeout: durationpb.New(applyTimeout),
}, devName, nil
}
// FailRollback закриває намір, який не поїхав.
func (s *Store) FailRollback(ctx context.Context, rollbackID, msg string) error {
_, err := s.bg.Exec(ctx, `
UPDATE ncm.rollbacks
SET status = 'failed', error = $2, applied_at = COALESCE(applied_at, now())
WHERE id = $1
`, rollbackID, msg)
return err
}
// ApplyReport — те, що приїхало від зонда.
type ApplyReport struct {
RollbackID string
Success bool
Committed bool
Outcomes []ApplyOutcome
Transcript string
Error string
}
// FinishApply записує результат заливки й ЗАВЖДИ призначає перевірку.
//
// Саме завжди — і в цьому головна думка всього файлу. Успішний вивід
// CLI не доводить, що конфіг застосувався: половина платформ мовчки
// ігнорує рядок, який не підходить до контексту. А невдалий вивід не
// доводить, що не застосувалось НІЧОГО: якщо зупинились на третій
// команді з двадцяти, перші дві вже на пристрої, і саме такий
// напівзалитий стан найнебезпечніший.
//
// Тому обидві гілки ведуть в одне місце: піти й зняти конфіг наново.
// Пристрій поруч, його можна спитати — здогадуватись тут нема про що.
func (s *Store) FinishApply(ctx context.Context, tenantID string, rep ApplyReport,
redact []string) error {
outcomes := rep.Outcomes
for i := range outcomes {
outcomes[i].Output = redactText(redact, outcomes[i].Output)
outcomes[i].Command = redactText(redact, outcomes[i].Command)
}
raw, err := json.Marshal(outcomes)
if err != nil {
return err
}
var deviceID string
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
UPDATE ncm.rollbacks
SET status = 'verifying',
applied_at = now(),
outcomes = $2::jsonb,
committed = $3,
error = NULLIF($4,''),
result_log = NULLIF($5,'')
WHERE id = $1 AND status IN ('applying','approved')
RETURNING device_id::text
`, rep.RollbackID, string(raw), rep.Committed, rep.Error,
redactText(redact, rep.Transcript)).Scan(&deviceID)
})
if errors.Is(err, pgx.ErrNoRows) {
// Намір уже закрив прибиральник. Результат при цьому не
// губиться: він приїхав із затримкою, і стан, у якому намір
// зараз, чесніший за той, який ми хотіли б записати.
return nil
}
if err != nil {
return err
}
jobID, err := s.enqueueVerifyJob(ctx, tenantID, deviceID)
if err != nil {
// Перевірити не вийде — і це саме по собі привід кричати:
// конфіг залито, а що на пристрої, ми не знаємо.
return s.markRollbackUnverifiable(ctx, rep.RollbackID,
"конфіг залито, але контрольний збір не вдалося поставити в чергу: "+err.Error())
}
_, err = s.bg.Exec(ctx, `
UPDATE ncm.rollbacks SET verify_job_id = $2 WHERE id = $1
`, rep.RollbackID, jobID)
return err
}
func (s *Store) markRollbackUnverifiable(ctx context.Context, rollbackID, msg string) error {
_, err := s.bg.Exec(ctx, `
UPDATE ncm.rollbacks
SET status = 'failed',
error = COALESCE(NULLIF(error,'') || '; ', '') || $2
WHERE id = $1
`, rollbackID, msg)
return err
}
// SettleRollbackVerification підбиває підсумок контрольного збору.
//
// План будується ще раз — тепер із СВІЖОГО конфігу проти тієї самої
// цільової версії. Нуль команд означає, що пристрій справді став таким,
// як хотіли. Не нуль — стан 'mismatch' і саме це число в інтерфейсі:
// воно відповідає на питання «наскільки не вийшло» краще за будь-який
// текст.
//
// Чому не порівняння хешів. Свіжий конфіг ніколи не збігається з
// архівним побайтово: у ньому інший час, інший uptime, інші лічильники.
// Хеш казав би «не збіглось» завжди, і стан 'mismatch' за тиждень
// перестали б читати. План же порівнює рівно те, що вміє залити.
func (s *Store) SettleRollbackVerification(ctx context.Context, ring *crypto.Keyring) (int, error) {
type pending struct {
id, tenantID, deviceID, targetID, jobStatus string
}
rows, err := s.bg.Query(ctx, `
SELECT r.id::text, r.tenant_id::text, r.device_id::text,
r.target_config_id::text, COALESCE(j.status::text, '')
FROM ncm.rollbacks r
LEFT JOIN ncm.jobs j ON j.id = r.verify_job_id
WHERE r.status = 'verifying'
LIMIT 100
`)
if err != nil {
return 0, err
}
var list []pending
for rows.Next() {
var p pending
if err := rows.Scan(&p.id, &p.tenantID, &p.deviceID, &p.targetID, &p.jobStatus); err != nil {
rows.Close()
return 0, err
}
list = append(list, p)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
settled := 0
for _, p := range list {
switch p.jobStatus {
case "queued", "running":
continue
case "":
_ = s.markRollbackUnverifiable(ctx, p.id, "контрольний збір загубився")
settled++
continue
case "failed", "timeout":
_ = s.markRollbackUnverifiable(ctx, p.id,
"конфіг залито, але контрольний збір не вдався — стан пристрою невідомий")
settled++
continue
}
remaining, freshID, err := s.remainingAfterApply(ctx, p.tenantID, p.deviceID, p.targetID, ring)
if err != nil {
_ = s.markRollbackUnverifiable(ctx, p.id,
"конфіг залито, але звірити його з цільовою версією не вдалося: "+err.Error())
settled++
continue
}
status := "mismatch"
if remaining == 0 {
status = "applied"
}
if _, err := s.bg.Exec(ctx, `
UPDATE ncm.rollbacks
SET status = $2::ncm.rollback_status,
verify_config_id = NULLIF($3,'')::uuid,
verify_remaining = $4
WHERE id = $1 AND status = 'verifying'
`, p.id, status, freshID, remaining); err != nil {
return settled, err
}
settled++
}
return settled, nil
}
// remainingAfterApply — скільки команд плану ще потрібні.
func (s *Store) remainingAfterApply(ctx context.Context, tenantID, deviceID, targetID string,
ring *crypto.Keyring) (int, string, error) {
targetBody, targetVer, err := s.ConfigBody(ctx, tenantID, targetID, ring)
if err != nil {
return 0, "", err
}
freshID, err := s.latestConfigID(ctx, tenantID, deviceID, targetVer.ConfigType)
if err != nil {
return 0, "", err
}
if freshID == "" {
return 0, "", errors.New("свіжої версії немає")
}
freshBody, _, err := s.ConfigBody(ctx, tenantID, freshID, ring)
if err != nil {
return 0, freshID, err
}
prof, err := s.applyProfileOf(ctx, tenantID, deviceID)
if err != nil {
return 0, freshID, err
}
plan, err := BuildRollbackPlan(freshBody, targetBody, prof)
if errors.Is(err, ErrRollbackNothing) {
return 0, freshID, nil
}
if err != nil {
return 0, freshID, err
}
// Рядки «прибрати вручну» теж рахуються розбіжністю: пристрій від
// них не став таким, як хотіли, і ховати це за нулем означало б
// звітувати про відкат, якого не сталося.
return len(plan.Commands) + len(plan.Manual), freshID, nil
}
// ReapStuckRollbacks розбирається з тими, від кого немає відповіді.
//
// Найважливіші п'ятнадцять рядків у файлі. Зонд міг зникнути посеред
// заливки: половина команд уже на пристрої, друга — ні, а результат не
// приїде ніколи. Це найгірший стан, у якому може опинитись вузол, і
// найгірша реакція на нього — записати 'failed' і заспокоїтись, бо
// 'failed' читається як «нічого не сталось».
//
// Тому такий намір іде не у відмову, а в перевірку: сервер ставить
// контрольний збір і дивиться, що реально стало на пристрої. Відповідь
// буде або 'applied' (устигло все), або 'mismatch' із числом
// недоробленого — і в обох випадках це знання, а не здогад.
func (s *Store) ReapStuckRollbacks(ctx context.Context) (int64, error) {
rows, err := s.bg.Query(ctx, `
UPDATE ncm.rollbacks
SET status = 'verifying',
applied_at = COALESCE(applied_at, now()),
error = COALESCE(NULLIF(error,'') || '; ', '') ||
'зонд не відповів під час заливки — перевіряємо, що реально стало на пристрої'
WHERE status = 'applying' AND sent_at < now() - $1::interval
RETURNING id::text, tenant_id::text, device_id::text
`, verifyGrace.String())
if err != nil {
return 0, err
}
type stuck struct{ id, tenantID, deviceID string }
var list []stuck
for rows.Next() {
var st stuck
if err := rows.Scan(&st.id, &st.tenantID, &st.deviceID); err != nil {
rows.Close()
return 0, err
}
list = append(list, st)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
for _, st := range list {
jobID, err := s.enqueueVerifyJob(ctx, st.tenantID, st.deviceID)
if err != nil {
_ = s.markRollbackUnverifiable(ctx, st.id,
"контрольний збір не вдалося поставити в чергу: "+err.Error())
continue
}
_, _ = s.bg.Exec(ctx, `UPDATE ncm.rollbacks SET verify_job_id = $2 WHERE id = $1`,
st.id, jobID)
}
// Наміри, що зависли вже в перевірці: контрольний збір теж може не
// доїхати. Довше мовчати не можна — стан пристрою невідомий, і про
// це має бути видно.
if _, err := s.bg.Exec(ctx, `
UPDATE ncm.rollbacks
SET status = 'failed',
error = COALESCE(NULLIF(error,'') || '; ', '') ||
'контрольний збір не завершився — стан пристрою невідомий'
WHERE status = 'verifying' AND applied_at < now() - $1::interval
`, (2 * verifyGrace).String()); err != nil {
return int64(len(list)), err
}
return int64(len(list)), nil
}
// RollbackRedactPatterns — зразки маскування для наміру.
//
// Потрібні приймачу результату: він отримує стенограму й вивід команд,
// у яких пристрій відлунив рядки конфігу — зокрема ті, що містять
// паролі. Профіль підбирається тим самим шляхом, що й для заливки:
// брати redact_patterns лише з явно закріпленого профілю означало б
// лишити без маскування саме ті хости, яким профіль підібрався сам.
func (s *Store) RollbackRedactPatterns(ctx context.Context, rollbackID string) (string, []string, error) {
var tenantID, deviceID string
err := s.bg.QueryRow(ctx, `
SELECT tenant_id::text, device_id::text FROM ncm.rollbacks WHERE id = $1
`, rollbackID).Scan(&tenantID, &deviceID)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil, ErrNotFound
}
if err != nil {
return "", nil, err
}
// Профіль міг зникнути разом із хостом. Порожній перелік не
// означає «не маскувати»: запасний перелік у redactLines працює
// завжди й саме на цей випадок.
prof, perr := s.applyProfileOf(ctx, tenantID, deviceID)
if perr != nil {
return tenantID, nil, nil
}
return tenantID, prof.RedactPatterns, nil
}
// enqueueVerifyJob ставить контрольний збір — власний, без дедуплікації.
//
// EnqueueConfigJob свідомо не множить однакові збори: два queued на
// один хост зберуть той самий конфіг. Для перевірки після відкату це
// правило хибне. Збір, що вже виконується, міг початись ДО заливки, і
// його результат показав би стан пристрою до відкату — тобто відповів
// би на питання, якого ми не ставили. Перевірка має бути власним
// збором, початим після запису, навіть ціною зайвої сесії.
func (s *Store) enqueueVerifyJob(ctx context.Context, tenantID, deviceID string) (string, error) {
var id string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
INSERT INTO ncm.jobs (tenant_id, device_id, agent_id, trigger)
SELECT $1, d.id, d.agent_id, 'api'::ncm.job_trigger
FROM inv.devices d
WHERE d.id = $2 AND d.tenant_id = $1 AND d.deleted_at IS NULL
RETURNING id::text
`, tenantID, deviceID).Scan(&id)
})
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNotFound
}
return id, err
}