Етап 7: планувальник бекапів за cron

Збір конфігів перестав залежати від того, чи згадає людина натиснути
кнопку.

Свій парсер cron (internal/cronx) замість залежності: бітові маски
uint64 на поле, пошук наступного запуску покроково по хвилинах із
запобіжником у чотири роки. Правило dom/dow — об'єднання, як у справжнього
cron. Неможливий розклад (30 лютого) чесно відмовляє замість зациклення.

Планувальник тікає раз на хвилину під advisory-блокуванням, тож у
кластері розклад розкручує рівно один екземпляр. Спершу переноситься
next_backup_at, потім ставиться завдання: падіння між кроками коштує
одного пропущеного бекапу, зворотний порядок дав би нескінченну чергу.

Форма розкладу: чотири пресети плюс довільний cron. Виправлено помилку,
через яку пункт «свій розклад…» нічого не робив — обробник select
відсікав порожнє значення, тобто саме той випадок, заради якого пункт
існує.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-24 12:54:07 +03:00
parent cdf5f1fb54
commit 436eff343d
13 changed files with 1163 additions and 18 deletions

View file

@ -1299,7 +1299,82 @@ diff @@ 8,3 +8,4 @@, один доданий рядок «new-c
### Чого ще немає
Планувальника за `cron` — збір лише вручну. Тригера за Syslog.
Git-двигуна: `commit_sha` поки дорівнює хешу вмісту, справжнього
репозиторію немає. Порівняння з довільною версією в UI (API вміє через
`?from=`, кнопки немає).
Тригера за Syslog. Git-двигуна: `commit_sha` поки дорівнює хешу вмісту,
справжнього репозиторію немає. Порівняння з довільною версією в UI (API
вміє через `?from=`, кнопки немає).
---
## Планувальник бекапів за cron
Збір конфігів працював, але лише руками. Це не NCM: сенс бекапу в
тому, що він робиться сам, а «зайти й натиснути» — це та сама
відсутність бекапу, просто з кращим самопочуттям.
### Свій парсер cron замість бібліотеки
`robfig/cron` зробив би це за десять рядків, але тягнув би залежність
заради того, що вміщується в одному файлі й не змінюється з 1975 року.
Розбір — у бітові маски `uint64` на поле, пошук наступного запуску —
покроково по хвилинах із запобіжником у чотири роки: перебір дешевший
за арифметику з календарем і не має її крайніх випадків.
**Правило dom/dow — об'єднання, не перетин.** Якщо задані і день
місяця, і день тижня, підходить збіг за будь-яким. Це виглядає
неочікувано, доки не спробуєш записати «щоп'ятниці та першого числа» —
іншого способу немає.
**Неможливий розклад чесно відмовляє.** `0 0 30 2 *` — 30 лютого не
буває; запобіжник у чотири роки перетворює це на помилку, а не на
зациклений процес. 29 лютого при цьому знаходиться правильно (2028).
11 тестів на парсер пройшли з першого запуску.
### Планувальник
Тік раз на хвилину — дрібніше cron не буває. Advisory-блокування
(`0x6e70_6263`, «npbc»), бо інакше кожен екземпляр сервера в кластері
поставив би своє завдання на той самий хост.
**Порядок кроків важливий: спершу перенести `next_backup_at`, потім
ставити завдання.** Падіння між кроками коштує одного пропущеного
бекапу. Зворотний порядок дав би нескінченну чергу однакових завдань —
а це кладе і пристрій, і сервер.
**Некоректний cron відсувається на добу, а не пишеться в журнал
щохвилини.** Скарга має лишитись помітною, але не перетворитись на шум,
у якому потонуть справжні помилки.
**`next_backup_at IS NULL` = «час настав».** Так виглядає щойно
збережена політика. З цієї ж причини будь-яка зміна політики обнуляє
позначку: розклад міг стати частішим, і чекати за старим було б
неправильно.
### Форма
Чотири готові розклади покривають майже все, що справді налаштовують;
довільний cron лишився, але не першим, що бачить людина.
**Знайдена й виправлена помилка:** вибір «свій розклад…» нічого не
робив. Обробник `select` мав вигляд `e.target.value && setCron(...)`, а
значення цього пункту — порожній рядок, тож умова відсікала саме той
випадок, заради якого пункт існує. Режим тепер тримається окремим
прапорцем, а не виводиться з виразу: інакше поле не відкривалось би й
тоді, коли власний вираз випадково збігся з пресетом.
### Перевірено наживо
Розклад `* * * * *` на stand-host:
```
trigger | status | створено
schedule | unchanged | 09:39:08
наступний | останній
09:40:00 | 09:20:31
```
У браузері: вибір «свій розклад…» відкриває поле з поточним виразом;
`0 99 * * *` відхиляється сервером із текстом «розклад: години: 99 поза
межами 0..23» і модалка лишається відкритою; `*/30 * * * *`
зберігається, і заголовок хоста стає «за розкладом */30 * * * *».
Розклад повернуто на `0 3 * * *`.

View file

@ -160,10 +160,11 @@ JSONPath, `discard unchanged`) — на сервері при записі: ін
## Етап 7. NCM — збір конфігів до кінця — ⚠️ наполовину, 2026-08-16
> **Зроблено:** агентський модуль SSH/Telnet, черга завдань, диспетчер,
> вивантаження зі стисненням і дедуплікацією. Перевірено наскрізно.
> вивантаження зі стисненням і дедуплікацією, візуальний diff у вебі,
> планувальник за cron. Перевірено наскрізно.
>
> **Лишилось:** планувальник за cron, тригер за Syslog, Git-двигун,
> візуальний diff у вебі.
> **Лишилось:** тригер за Syslog, Git-двигун, порівняння з довільною
> версією в UI.
## Етап 7 (початковий план)
@ -176,9 +177,10 @@ JSONPath, `discard unchanged`) — на сервері при записі: ін
регуляркою, віддає сирий текст чанками. Для `bdcom-olt` і `mikrotik-routeros`
профілі вже в сіді — вони й стануть першими підопічними.
**Планувальник на сервері.** Cron із `ncm.device_policies.cron` плюс тригер за
Syslog-подією (`on_syslog`, `%SYS-5-CONFIG_I`) — обидва поля в схемі є, читати їх
нікому. Видає `ConfigJob` у живу сесію зонда.
**Планувальник на сервері.** ✅ Cron із `ncm.device_policies.cron` — власний
парсер `internal/cronx`, тік раз на хвилину під advisory-блокуванням. Лишився
тригер за Syslog-подією (`on_syslog`, `%SYS-5-CONFIG_I`) — поле в схемі є,
читати його нікому.
**Git-двигун.** `go-git` замість libgit2 — **чистий Go, без cgo**, що зберігає
статичний бінарник без залежностей. Комітить у `refs/heads/device/<id>`, віддає

View file

@ -529,6 +529,9 @@ Qtech, ZTE, BDCOM та інші.
| `GET` | `/api/v1/devices/{id}/configs` | версії конфігу |
| `GET` | `/api/v1/configs/{id}` | текст версії |
| `GET` | `/api/v1/configs/{id}/diff` | порівняння з попередньою (`?from=…`) |
| `GET` | `/api/v1/devices/{id}/backup-policy` | розклад бекапу хоста |
| `PUT` | `/api/v1/devices/{id}/backup-policy` | змінити розклад (`ncm:write`) |
| `GET` | `/api/v1/ncm-profiles` | довідник профілів для форми |
Ланцюг такий: REST кладе рядок у `ncm.jobs` зі станом `queued`
диспетчер усередині AgentService забирає його, якщо потрібний зонд на
@ -546,6 +549,48 @@ LOCKED`: два екземпляри AgentService не надішлють одн
відсутність результату, і окремий статус потрібен, щоб у журналі було
видно, коли конфіг востаннє **справді** мінявся.
### Розклад
`PUT /api/v1/devices/{id}/backup-policy` приймає:
```json
{
"enabled": true,
"cron": "0 3 * * *",
"profile_id": "",
"credential_id": ""
}
```
Порожні `profile_id` і `credential_id` означають «підібрати
автоматично»: профіль — за виробником хоста, доступ — прив'язаний до
хоста. Явно заданий завжди виграє: так лікується прошивка, що
поводиться не як решта родини.
**Вираз cron розбирається власним парсером** (`internal/cronx`),
п'ятипольовий, як у crontab: хвилина, година, день місяця, місяць,
день тижня. Підтримані `*`, списки `1,2`, діапазони `8-20`, кроки
`*/15` і `8-20/4`, назви місяців і днів (`jan`, `mon`), неділя і як
`0`, і як `7`. Якщо задані одночасно день місяця й день тижня,
підходить збіг за **будь-яким** — це правило самого cron, інакше
«щоп'ятниці та першого числа» не записати.
Некоректний вираз відхиляється одразу з `400 bad_cron` і людським
поясненням (`розклад: години: 99 поза межами 0..23`) — краще сказати це
у формі, ніж мовчки не робити бекапів.
Планувальник усередині AgentService прокидається раз на хвилину (cron
дрібніший за хвилину не буває) під advisory-блокуванням, тож у кластері
розклад розкручує рівно один екземпляр. Порядок кроків — спершу
перенести `next_backup_at`, потім поставити завдання: падіння між ними
коштує одного пропущеного бекапу, а зворотний порядок дав би
нескінченну чергу однакових завдань.
`next_backup_at IS NULL` вважається «час настав»: так виглядає щойно
збережена політика, і чекати добу до першого бекапу означало б не
зробити його тоді, коли він найпотрібніший. З цієї ж причини будь-яка
зміна політики обнуляє позначку.
Зависле в `running` завдання повертається у відмову за десять хвилин:
зонд міг зникнути разом із ним, і без цього хост лишився б без бекапів
назавжди.

View file

@ -0,0 +1,225 @@
// Package cronx — розбір розкладу у форматі cron і обчислення
// наступного запуску.
//
// Власна реалізація на п'ять полів. Бібліотеки вміють ще й секунди,
// роки, «@every 5m» і власні часові пояси — а тут потрібне рівно те,
// що людина пише в полі «розклад бекапу», і жодного способу
// прострелити собі ногу нестандартним синтаксисом.
package cronx
import (
"fmt"
"strconv"
"strings"
"time"
)
// Schedule — розібраний розклад.
//
// Кожне поле — множина дозволених значень. Бітова маска замість
// переліку: перевірка «чи підходить хвилина» стає одним зсувом, а
// шукати наступний запуск доводиться перебором хвилин.
type Schedule struct {
minute uint64 // 0..59
hour uint64 // 0..23
dom uint64 // 1..31
month uint64 // 1..12
dow uint64 // 0..6, неділя = 0
// domRestricted і dowRestricted кажуть, чи задане поле явно.
// Потрібні для правила cron: якщо обидва задані, збіг за будь-яким
// із них уже підходить. Це не примха, а спосіб записати «щоп'ятниці
// та 1-го числа» одним рядком.
domRestricted bool
dowRestricted bool
}
type fieldSpec struct {
min, max int
names map[string]int
}
var (
minuteSpec = fieldSpec{0, 59, nil}
hourSpec = fieldSpec{0, 23, nil}
domSpec = fieldSpec{1, 31, nil}
monthSpec = fieldSpec{1, 12, map[string]int{
"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
"jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12,
}}
dowSpec = fieldSpec{0, 6, map[string]int{
"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6,
}}
)
// Parse розбирає «хвилина година день місяць день_тижня».
func Parse(expr string) (*Schedule, error) {
fields := strings.Fields(strings.TrimSpace(expr))
if len(fields) != 5 {
return nil, fmt.Errorf("очікується п'ять полів, отримано %d", len(fields))
}
s := &Schedule{}
var err error
if s.minute, err = parseField(fields[0], minuteSpec); err != nil {
return nil, fmt.Errorf("хвилини: %w", err)
}
if s.hour, err = parseField(fields[1], hourSpec); err != nil {
return nil, fmt.Errorf("години: %w", err)
}
if s.dom, err = parseField(fields[2], domSpec); err != nil {
return nil, fmt.Errorf("день місяця: %w", err)
}
if s.month, err = parseField(fields[3], monthSpec); err != nil {
return nil, fmt.Errorf("місяць: %w", err)
}
if s.dow, err = parseField(fields[4], dowSpec); err != nil {
return nil, fmt.Errorf("день тижня: %w", err)
}
s.domRestricted = fields[2] != "*"
s.dowRestricted = fields[4] != "*"
return s, nil
}
func parseField(f string, spec fieldSpec) (uint64, error) {
var mask uint64
for _, part := range strings.Split(f, ",") {
m, err := parsePart(strings.TrimSpace(part), spec)
if err != nil {
return 0, err
}
mask |= m
}
if mask == 0 {
return 0, fmt.Errorf("порожній набір значень")
}
return mask, nil
}
func parsePart(p string, spec fieldSpec) (uint64, error) {
if p == "" {
return 0, fmt.Errorf("порожня частина")
}
step := 1
if base, stepStr, ok := strings.Cut(p, "/"); ok {
n, err := strconv.Atoi(stepStr)
if err != nil || n <= 0 {
return 0, fmt.Errorf("некоректний крок %q", stepStr)
}
step = n
p = base
}
lo, hi := spec.min, spec.max
switch {
case p == "*":
// уже lo..hi
case strings.Contains(p, "-"):
a, b, _ := strings.Cut(p, "-")
var err error
if lo, err = parseValue(a, spec); err != nil {
return 0, err
}
if hi, err = parseValue(b, spec); err != nil {
return 0, err
}
if lo > hi {
return 0, fmt.Errorf("діапазон %q іде навспак", p)
}
default:
v, err := parseValue(p, spec)
if err != nil {
return 0, err
}
lo, hi = v, v
// «5/10» без діапазону означає «від 5 і далі кроком», як у
// класичному cron.
if step > 1 {
hi = spec.max
}
}
var mask uint64
for v := lo; v <= hi; v += step {
mask |= 1 << uint(v)
}
return mask, nil
}
func parseValue(v string, spec fieldSpec) (int, error) {
v = strings.TrimSpace(v)
if spec.names != nil {
if n, ok := spec.names[strings.ToLower(v)]; ok {
return n, nil
}
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("не число й не назва: %q", v)
}
// Неділя як 7 — поширений запис, і відхиляти його було б
// причепливістю.
if spec.max == 6 && n == 7 {
n = 0
}
if n < spec.min || n > spec.max {
return 0, fmt.Errorf("%d поза межами %d..%d", n, spec.min, spec.max)
}
return n, nil
}
// Next повертає найближчий момент після from, який підходить розкладу.
//
// Перебором по хвилинах із обмеженням у чотири роки: високосний рік і
// «29 лютого» роблять аналітичне обчислення помітно складнішим за
// вигоду, а перебір знаходить відповідь за мілісекунди навіть у
// найгіршому випадку.
func (s *Schedule) Next(from time.Time) (time.Time, error) {
t := from.Truncate(time.Minute).Add(time.Minute)
limit := from.AddDate(4, 0, 0)
for t.Before(limit) {
if s.month&(1<<uint(t.Month())) == 0 {
// Місяць не підходить — стрибаємо на початок наступного.
t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()).AddDate(0, 1, 0)
continue
}
if !s.dayMatches(t) {
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, 1)
continue
}
if s.hour&(1<<uint(t.Hour())) == 0 {
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location()).
Add(time.Hour)
continue
}
if s.minute&(1<<uint(t.Minute())) == 0 {
t = t.Add(time.Minute)
continue
}
return t, nil
}
return time.Time{}, fmt.Errorf("розклад не має жодного запуску в найближчі роки")
}
// dayMatches реалізує правило cron про день місяця й день тижня.
//
// Якщо задане лише одне з полів — діє воно. Якщо обидва — підходить
// збіг за будь-яким. Це виглядає нелогічно, доки не спробуєш записати
// «щоп'ятниці та 1-го числа»: іншого способу немає.
func (s *Schedule) dayMatches(t time.Time) bool {
domOK := s.dom&(1<<uint(t.Day())) != 0
dowOK := s.dow&(1<<uint(t.Weekday())) != 0
switch {
case s.domRestricted && s.dowRestricted:
return domOK || dowOK
case s.domRestricted:
return domOK
case s.dowRestricted:
return dowOK
default:
return true
}
}

View file

@ -0,0 +1,146 @@
package cronx
import (
"testing"
"time"
)
func at(t *testing.T, s string) time.Time {
t.Helper()
v, err := time.Parse("2006-01-02 15:04", s)
if err != nil {
t.Fatal(err)
}
return v
}
func next(t *testing.T, expr, from string) string {
t.Helper()
s, err := Parse(expr)
if err != nil {
t.Fatalf("Parse(%q): %v", expr, err)
}
n, err := s.Next(at(t, from))
if err != nil {
t.Fatalf("Next: %v", err)
}
return n.Format("2006-01-02 15:04")
}
func TestDailyAtThree(t *testing.T) {
if got := next(t, "0 3 * * *", "2026-08-16 10:00"); got != "2026-08-17 03:00" {
t.Fatalf("отримали %s", got)
}
// Якщо ще не настало сьогодні — сьогодні ж.
if got := next(t, "0 3 * * *", "2026-08-16 01:30"); got != "2026-08-16 03:00" {
t.Fatalf("отримали %s", got)
}
}
func TestEveryFifteenMinutes(t *testing.T) {
if got := next(t, "*/15 * * * *", "2026-08-16 10:07"); got != "2026-08-16 10:15" {
t.Fatalf("отримали %s", got)
}
// Рівно на межі — наступний, а не той самий.
if got := next(t, "*/15 * * * *", "2026-08-16 10:15"); got != "2026-08-16 10:30" {
t.Fatalf("отримали %s", got)
}
}
func TestListAndRange(t *testing.T) {
if got := next(t, "0 9,18 * * *", "2026-08-16 10:00"); got != "2026-08-16 18:00" {
t.Fatalf("список годин: %s", got)
}
if got := next(t, "30 8-10 * * *", "2026-08-16 09:45"); got != "2026-08-16 10:30" {
t.Fatalf("діапазон: %s", got)
}
}
// 2026-08-16 — неділя; найближчий понеділок 17-го.
func TestDayOfWeek(t *testing.T) {
if got := next(t, "0 2 * * 1", "2026-08-16 12:00"); got != "2026-08-17 02:00" {
t.Fatalf("отримали %s", got)
}
if got := next(t, "0 2 * * mon", "2026-08-16 12:00"); got != "2026-08-17 02:00" {
t.Fatalf("назва дня: %s", got)
}
// Неділя як 7 — поширений запис.
if got := next(t, "0 2 * * 7", "2026-08-17 12:00"); got != "2026-08-23 02:00" {
t.Fatalf("неділя як 7: %s", got)
}
}
func TestDayOfMonth(t *testing.T) {
if got := next(t, "0 4 1 * *", "2026-08-16 12:00"); got != "2026-09-01 04:00" {
t.Fatalf("отримали %s", got)
}
}
// Правило cron: якщо задані і день місяця, і день тижня, підходить
// збіг за будь-яким. «Щоп'ятниці та 1-го числа» інакше не записати.
func TestDomAndDowAreUnion(t *testing.T) {
s, err := Parse("0 5 1 * fri")
if err != nil {
t.Fatal(err)
}
// 2026-08-21 — п'ятниця.
n, _ := s.Next(at(t, "2026-08-20 12:00"))
if got := n.Format("2006-01-02"); got != "2026-08-21" {
t.Fatalf("п'ятниця: %s", got)
}
// 2026-09-01 — вівторок, але перше число.
n, _ = s.Next(at(t, "2026-08-29 12:00"))
if got := n.Format("2006-01-02"); got != "2026-09-01" {
t.Fatalf("перше число: %s", got)
}
}
func TestMonthNames(t *testing.T) {
if got := next(t, "0 0 1 jan *", "2026-08-16 12:00"); got != "2027-01-01 00:00" {
t.Fatalf("отримали %s", got)
}
}
// 29 лютого існує не щороку — перебір має це переживати, а не зациклитись.
func TestLeapDay(t *testing.T) {
if got := next(t, "0 0 29 2 *", "2026-03-01 00:00"); got != "2028-02-29 00:00" {
t.Fatalf("отримали %s", got)
}
}
func TestStepInRange(t *testing.T) {
if got := next(t, "0 8-20/4 * * *", "2026-08-16 09:00"); got != "2026-08-16 12:00" {
t.Fatalf("отримали %s", got)
}
}
func TestBadExpressions(t *testing.T) {
bad := []string{
"", "0 3 * *", "0 3 * * * *",
"60 3 * * *", "0 24 * * *", "0 3 0 * *", "0 3 * 13 *",
"0 3 * * 8", "x 3 * * *", "*/0 * * * *", "10-5 * * * *",
}
for _, e := range bad {
if _, err := Parse(e); err == nil {
t.Errorf("%q мав дати помилку", e)
}
}
}
// Розклад, який ніколи не настане, має чесно відмовити, а не зависнути.
func TestImpossibleScheduleFails(t *testing.T) {
s, err := Parse("0 0 30 2 *") // 30 лютого не буває
if err != nil {
t.Fatal(err)
}
if _, err := s.Next(at(t, "2026-01-01 00:00")); err == nil {
t.Fatal("очікували відмову")
}
}
// Типовий розклад із seed має розбиратись.
func TestDefaultPolicyExpression(t *testing.T) {
if _, err := Parse("0 3 * * *"); err != nil {
t.Fatalf("типовий розклад не розбирається: %v", err)
}
}

View file

@ -0,0 +1,108 @@
package grpcapi
import (
"context"
"time"
"github.com/netpulse/netpulse/server/internal/cronx"
)
// scheduleLockKey — окреме advisory-блокування під планувальник бекапів.
const scheduleLockKey = 0x6e70_6263 // "npbc"
// ScheduleBackups ставить бекапи в чергу за розкладом.
//
// Раз на хвилину, бо cron дрібніший за хвилину не буває. Кілька
// екземплярів сервера безпечні: тік бере advisory-блокування, тож
// розклад розкручує рівно один — інакше кожен хост отримував би стільки
// завдань, скільки інстансів у кластері.
func (s *Service) ScheduleBackups(ctx context.Context) {
t := time.NewTicker(time.Minute)
defer t.Stop()
s.log.Info("планувальник бекапів запущено")
for {
select {
case <-ctx.Done():
return
case <-t.C:
if n := s.scheduleTick(ctx); n > 0 {
s.log.Info("бекапи поставлено в чергу за розкладом", "хостів", n)
}
}
}
}
func (s *Service) scheduleTick(ctx context.Context) int {
conn, err := s.store.Pool().Acquire(ctx)
if err != nil {
s.log.Error("планувальник: з'єднання", "err", err)
return 0
}
defer conn.Release()
var got bool
if err := conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1)`,
int64(scheduleLockKey)).Scan(&got); err != nil {
s.log.Error("планувальник: блокування", "err", err)
return 0
}
if !got {
// Розклад розкручує інший інстанс — штатний стан.
return 0
}
defer func() {
_, _ = conn.Exec(context.WithoutCancel(ctx),
`SELECT pg_advisory_unlock($1)`, int64(scheduleLockKey))
}()
due, err := s.store.DuePolicies(ctx, 200)
if err != nil {
s.log.Error("планувальник: вибірка розкладів", "err", err)
return 0
}
now := time.Now()
queued := 0
for _, d := range due {
sched, err := cronx.Parse(d.Cron)
if err != nil {
// Некоректний розклад не має зупиняти решту й не має
// повторюватись щохвилини у журналі: відсуваємо політику на
// добу, щоб скарга лишилась помітною, але не набридала.
s.log.Warn("некоректний розклад бекапу",
"device", d.DeviceID, "cron", d.Cron, "err", err)
_ = s.store.SetNextBackup(ctx, d.DeviceID, now.Add(24*time.Hour))
continue
}
next, err := sched.Next(now)
if err != nil {
s.log.Warn("розклад без найближчого запуску",
"device", d.DeviceID, "cron", d.Cron)
_ = s.store.SetNextBackup(ctx, d.DeviceID, now.Add(24*time.Hour))
continue
}
// Спершу переносимо позначку, потім ставимо завдання.
//
// Саме в такому порядку: якщо процес упаде між двома кроками,
// хост пропустить один бекап. Зворотний порядок дав би
// нескінченну чергу однакових завдань — а це гірше, бо кладе
// й пристрій, і сервер.
if err := s.store.SetNextBackup(ctx, d.DeviceID, next); err != nil {
s.log.Error("планувальник: перенесення розкладу", "err", err)
continue
}
if _, err := s.store.EnqueueConfigJob(ctx, d.TenantID, d.DeviceID, "schedule", ""); err != nil {
s.log.Error("планувальник: постановка завдання",
"device", d.DeviceID, "err", err)
continue
}
queued++
}
return queued
}

View file

@ -6,6 +6,7 @@ import (
"net/http"
"strings"
"github.com/netpulse/netpulse/server/internal/cronx"
"github.com/netpulse/netpulse/server/internal/store"
)
@ -340,3 +341,92 @@ func (s *Server) handleDiffConfigs(w http.ResponseWriter, r *http.Request, p *Pr
}
writeJSON(w, http.StatusOK, res)
}
// ---------------------------------------------------------------------
// Розклад бекапу
// ---------------------------------------------------------------------
func (s *Server) handleGetBackupPolicy(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
deviceID := r.PathValue("id")
if !p.Scope().CanRead(deviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
return
}
pol, err := s.store.GetBackupPolicy(r.Context(), p.TenantID, deviceID)
if err != nil {
s.writeStoreError(w, "розклад бекапу", err)
return
}
// Політики немає — це не помилка, а «ще не налаштовано».
writeJSON(w, http.StatusOK, map[string]any{"policy": pol})
}
func (s *Server) handleSetBackupPolicy(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:write") {
return
}
deviceID := r.PathValue("id")
if !p.Scope().CanWrite(deviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу на запис до цього хоста")
return
}
var in struct {
ProfileID string `json:"profile_id"`
CredentialID string `json:"credential_id"`
Enabled *bool `json:"enabled"`
Cron string `json:"cron"`
OnSyslog *bool `json:"on_syslog"`
}
if !decodeBody(w, r, &in) {
return
}
if strings.TrimSpace(in.Cron) == "" {
in.Cron = "0 3 * * *"
}
// Розклад перевіряємо тут, а не в планувальнику: інакше людина
// дізнається про друкарську помилку через добу мовчання, а не
// одразу при збереженні.
if _, err := cronx.Parse(in.Cron); err != nil {
writeError(w, http.StatusBadRequest, "bad_cron", "розклад: "+err.Error())
return
}
err := s.store.SetBackupPolicy(r.Context(), p.TenantID, store.BackupPolicy{
DeviceID: deviceID,
ProfileID: in.ProfileID,
CredentialID: in.CredentialID,
Enabled: in.Enabled == nil || *in.Enabled,
Cron: in.Cron,
OnSyslog: in.OnSyslog != nil && *in.OnSyslog,
})
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "хост не знайдено")
return
}
if err != nil {
s.writeStoreError(w, "збереження розкладу", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleListProfiles — довідник профілів для форми розкладу.
func (s *Server) handleListProfiles(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "ncm:read") {
return
}
list, err := s.store.ListProfiles(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "перелік профілів", err)
return
}
if list == nil {
list = []map[string]any{}
}
writeJSON(w, http.StatusOK, map[string]any{"profiles": list})
}

View file

@ -96,6 +96,9 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /api/v1/devices/{id}/configs", s.authenticated(s.handleListConfigs))
mux.Handle("GET /api/v1/configs/{id}", s.authenticated(s.handleGetConfig))
mux.Handle("GET /api/v1/configs/{id}/diff", s.authenticated(s.handleDiffConfigs))
mux.Handle("GET /api/v1/devices/{id}/backup-policy", s.authenticated(s.handleGetBackupPolicy))
mux.Handle("PUT /api/v1/devices/{id}/backup-policy", s.authenticated(s.handleSetBackupPolicy))
mux.Handle("GET /api/v1/ncm-profiles", s.authenticated(s.handleListProfiles))
mux.Handle("GET /api/v1/check-types", s.authenticated(s.handleListCheckTypes))
mux.Handle("GET /api/v1/devices/{id}/checks", s.authenticated(s.handleListDeviceChecks))

View file

@ -0,0 +1,169 @@
package store
import (
"context"
"time"
"github.com/jackc/pgx/v5"
)
// BackupPolicy — розклад бекапу одного хоста.
type BackupPolicy struct {
DeviceID string `json:"device_id"`
ProfileID string `json:"profile_id,omitempty"`
CredentialID string `json:"credential_id,omitempty"`
Enabled bool `json:"enabled"`
Cron string `json:"cron"`
OnSyslog bool `json:"on_syslog"`
LastBackupAt *time.Time `json:"last_backup_at,omitempty"`
NextBackupAt *time.Time `json:"next_backup_at,omitempty"`
}
// DuePolicy — політика, якій настав час.
type DuePolicy struct {
TenantID string
DeviceID string
Cron string
}
// DuePolicies повертає розклади, час яких настав.
//
// Порожній next_backup_at теж вважається настанням: так виглядає щойно
// створена політика, і чекати на неї добу означало б не зробити перший
// бекап тоді, коли він найпотрібніший.
func (s *Store) DuePolicies(ctx context.Context, limit int) ([]DuePolicy, error) {
if limit <= 0 {
limit = 100
}
rows, err := s.pool.Query(ctx, `
SELECT p.tenant_id::text, p.device_id::text, p.cron
FROM ncm.device_policies p
JOIN inv.devices d ON d.id = p.device_id
JOIN core.tenants t ON t.id = p.tenant_id
WHERE p.enabled
AND d.enabled AND d.deleted_at IS NULL
AND t.status NOT IN ('suspended','cancelled')
AND (p.next_backup_at IS NULL OR p.next_backup_at <= now())
ORDER BY p.next_backup_at NULLS FIRST
LIMIT $1
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []DuePolicy
for rows.Next() {
var d DuePolicy
if err := rows.Scan(&d.TenantID, &d.DeviceID, &d.Cron); err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
// SetNextBackup переносить політику на наступний запуск.
//
// Викликається одразу після постановки завдання в чергу, а не після
// його виконання: інакше хост, до якого зонд не достукався, лишався б
// «простроченим» і породжував нове завдання щотіку.
func (s *Store) SetNextBackup(ctx context.Context, deviceID string, next time.Time) error {
_, err := s.pool.Exec(ctx, `
UPDATE ncm.device_policies SET next_backup_at = $2 WHERE device_id = $1
`, deviceID, next)
return err
}
// GetBackupPolicy читає політику хоста.
func (s *Store) GetBackupPolicy(ctx context.Context, tenantID, deviceID string) (*BackupPolicy, error) {
var p BackupPolicy
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
SELECT device_id::text, COALESCE(profile_id::text,''),
COALESCE(credential_id::text,''), enabled, cron, on_syslog,
last_backup_at, next_backup_at
FROM ncm.device_policies
WHERE device_id = $1 AND tenant_id = $2
`, deviceID, tenantID).Scan(&p.DeviceID, &p.ProfileID, &p.CredentialID,
&p.Enabled, &p.Cron, &p.OnSyslog, &p.LastBackupAt, &p.NextBackupAt)
})
if err != nil {
if isNoRows(err) {
return nil, nil
}
return nil, err
}
return &p, nil
}
// SetBackupPolicy створює або оновлює політику.
//
// next_backup_at обнуляється при кожній зміні: розклад міг стати
// частішим, і лишити стару позначку означало б чекати за старим
// розкладом до першого спрацювання нового.
func (s *Store) SetBackupPolicy(ctx context.Context, tenantID string, p BackupPolicy) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var owned bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM inv.devices
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL)
`, p.DeviceID, tenantID).Scan(&owned); err != nil {
return err
}
if !owned {
return ErrNotFound
}
_, err := tx.Exec(ctx, `
INSERT INTO ncm.device_policies
(device_id, tenant_id, profile_id, credential_id, enabled, cron,
on_syslog, next_backup_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL)
ON CONFLICT (device_id) DO UPDATE SET
profile_id = EXCLUDED.profile_id,
credential_id = EXCLUDED.credential_id,
enabled = EXCLUDED.enabled,
cron = EXCLUDED.cron,
on_syslog = EXCLUDED.on_syslog,
next_backup_at = NULL,
updated_at = now()
`, p.DeviceID, tenantID, nullUUID(p.ProfileID), nullUUID(p.CredentialID),
p.Enabled, p.Cron, p.OnSyslog)
return err
})
}
// ListProfiles — довідник профілів для форми.
func (s *Store) ListProfiles(ctx context.Context, tenantID string) ([]map[string]any, error) {
var out []map[string]any
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT id::text, key, name, COALESCE(vendor,''), is_builtin
FROM ncm.profiles
WHERE tenant_id IS NULL OR tenant_id = $1
ORDER BY vendor, name
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id, key, name, vendor string
var builtin bool
if err := rows.Scan(&id, &key, &name, &vendor, &builtin); err != nil {
return err
}
out = append(out, map[string]any{
"id": id, "key": key, "name": name,
"vendor": vendor, "is_builtin": builtin,
})
}
return rows.Err()
})
return out, err
}
func isNoRows(err error) bool {
return err != nil && err.Error() == pgx.ErrNoRows.Error()
}

View file

@ -8,6 +8,7 @@ import type {
BuildResult,
Channel,
Check,
BackupPolicy,
ConfigJobRow,
ConfigVersion,
CheckType,
@ -15,6 +16,7 @@ import type {
DeviceGroup,
DeviceSummary,
DiffResult,
NcmProfile,
LoginResponse,
MapPatch,
MapState,
@ -291,6 +293,27 @@ export const api = {
diffConfig: (id: string, from?: string) =>
request<DiffResult>(`/api/v1/configs/${id}/diff${from ? `?from=${from}` : ''}`),
getBackupPolicy: (deviceID: string) =>
request<{ policy: BackupPolicy | null }>(`/api/v1/devices/${deviceID}/backup-policy`)
.then((r) => r.policy),
setBackupPolicy: (
deviceID: string,
p: {
profile_id?: string
credential_id?: string
enabled: boolean
cron: string
on_syslog?: boolean
},
) => request<void>(`/api/v1/devices/${deviceID}/backup-policy`, {
method: 'PUT',
body: JSON.stringify(p),
}),
listNcmProfiles: () =>
request<{ profiles: NcmProfile[] }>('/api/v1/ncm-profiles').then((r) => r.profiles ?? []),
listCredentials: () =>
request<{ credentials: Credential[] }>('/api/v1/credentials').then((r) => r.credentials ?? []),

View file

@ -0,0 +1,196 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Button, ErrorNote, Field, Modal, Toggle, inputClass } from './ui'
import type { BackupPolicy, Credential, DeviceSummary, NcmProfile } from '../types'
// Готові розклади покривають майже все, що справді налаштовують.
// Довільний cron лишається, але не мусить бути першим, що бачить людина.
const PRESETS: { cron: string; label: string }[] = [
{ cron: '0 3 * * *', label: 'щодня о 03:00' },
{ cron: '0 */6 * * *', label: 'кожні 6 годин' },
{ cron: '0 3 * * 1', label: 'щопонеділка о 03:00' },
{ cron: '0 3 1 * *', label: 'першого числа о 03:00' },
]
/**
* Розклад резервного копіювання хоста.
*
* Профіль можна не задавати: сервер підбере його за виробником. Явно
* заданий завжди виграє це шлях для випадків, коли прошивка
* поводиться не так, як решта родини.
*/
export function BackupPolicyForm({
device,
credentials,
onClose,
}: {
device: DeviceSummary
credentials: Credential[]
onClose: () => void
}) {
const [profiles, setProfiles] = useState<NcmProfile[]>([])
const [policy, setPolicy] = useState<BackupPolicy | null>(null)
const [enabled, setEnabled] = useState(true)
const [cron, setCron] = useState('0 3 * * *')
// Окремий прапорець, а не висновок із самого виразу: вибір «свій
// розклад» має відкрити поле навіть тоді, коли поточне значення
// випадково збігається з пресетом.
const [customMode, setCustomMode] = useState(false)
const [profileID, setProfileID] = useState('')
const [credID, setCredID] = useState('')
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
useEffect(() => {
let alive = true
Promise.all([
api.listNcmProfiles().catch(() => [] as NcmProfile[]),
api.getBackupPolicy(device.id).catch(() => null),
])
.then(([pr, pol]) => {
if (!alive) return
setProfiles(pr)
setPolicy(pol)
if (pol) {
setEnabled(pol.enabled)
setCron(pol.cron)
setCustomMode(!PRESETS.some((x) => x.cron === pol.cron))
setProfileID(pol.profile_id ?? '')
setCredID(pol.credential_id ?? '')
}
})
.finally(() => alive && setLoading(false))
return () => {
alive = false
}
}, [device.id])
const cliCreds = credentials.filter((c) => c.proto === 'ssh' || c.proto === 'telnet')
const custom = customMode || !PRESETS.some((p) => p.cron === cron)
return (
<Modal title={`Резервне копіювання: ${device.name}`} onClose={onClose}>
{loading ? (
<p className="py-6 text-center text-xs text-slate-600">Завантаження</p>
) : (
<div className="space-y-3">
<label className="flex items-center gap-2.5 text-sm text-slate-300">
<Toggle checked={enabled} onChange={setEnabled} />
Збирати конфіг за розкладом
</label>
<Field label="Розклад">
<select
className={inputClass}
value={custom ? '' : cron}
onChange={(e) => {
if (e.target.value === '') {
setCustomMode(true)
return
}
setCustomMode(false)
setCron(e.target.value)
}}
>
{PRESETS.map((p) => (
<option key={p.cron} value={p.cron}>
{p.label}
</option>
))}
<option value="">свій розклад</option>
</select>
</Field>
{custom && (
<Field
label="Вираз cron"
hint="Хвилина, година, день місяця, місяць, день тижня — як у crontab"
>
<input
className={`${inputClass} font-mono`}
value={cron}
onChange={(e) => setCron(e.target.value)}
/>
</Field>
)}
<Field
label="Профіль"
hint="Не задано — сервер підбере за виробником хоста"
>
<select
className={inputClass}
value={profileID}
onChange={(e) => setProfileID(e.target.value)}
>
<option value=""> автоматично </option>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.key})
</option>
))}
</select>
</Field>
<Field
label="Доступ"
hint={
cliCreds.length === 0
? 'Немає жодного доступу ssh або telnet — без нього збір неможливий'
: 'Не задано — візьметься прив’язаний до хоста'
}
>
<select
className={inputClass}
value={credID}
onChange={(e) => setCredID(e.target.value)}
>
<option value=""> автоматично </option>
{cliCreds.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.proto})
</option>
))}
</select>
</Field>
{policy?.next_backup_at && enabled && (
<p className="text-[11px] text-slate-600">
Наступний запуск: {new Date(policy.next_backup_at).toLocaleString('uk-UA')}
</p>
)}
<ErrorNote>{err}</ErrorNote>
<div className="flex justify-end gap-2 pt-1">
<Button onClick={onClose}>Скасувати</Button>
<Button
kind="primary"
disabled={busy || cron.trim() === ''}
onClick={async () => {
setBusy(true)
setErr(null)
try {
await api.setBackupPolicy(device.id, {
profile_id: profileID,
credential_id: credID,
enabled,
cron: cron.trim(),
})
onClose()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}}
>
{busy ? 'Збереження…' : 'Зберегти'}
</Button>
</div>
</div>
)}
</Modal>
)
}

View file

@ -12,7 +12,15 @@ import {
ago,
inputClass,
} from '../components/ui'
import type { ConfigJobRow, ConfigVersion, DeviceSummary, DiffResult } from '../types'
import { BackupPolicyForm } from '../components/BackupPolicyForm'
import type {
BackupPolicy,
ConfigJobRow,
ConfigVersion,
Credential,
DeviceSummary,
DiffResult,
} from '../types'
const jobTone: Record<string, string> = {
success: 'up',
@ -45,14 +53,18 @@ export function ConfigsPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')
const [creds, setCreds] = useState<Credential[]>([])
const canCollect = session.can('ncm:write')
useEffect(() => {
api
.listDevices()
.then((d) => {
Promise.all([
api.listDevices(),
api.listCredentials().catch(() => [] as Credential[]),
])
.then(([d, c]) => {
setDevices(d)
setCreds(c)
setSelected((cur) => cur ?? d[0] ?? null)
})
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
@ -115,7 +127,7 @@ export function ConfigsPage() {
<div className="min-h-0 flex-1 overflow-y-auto p-3 md:p-4">
{selected ? (
<DeviceConfigs device={selected} canCollect={canCollect} />
<DeviceConfigs device={selected} canCollect={canCollect} credentials={creds} />
) : (
<p className="py-16 text-center text-sm text-slate-600">Оберіть хост</p>
)}
@ -127,22 +139,34 @@ export function ConfigsPage() {
)
}
function DeviceConfigs({ device, canCollect }: { device: DeviceSummary; canCollect: boolean }) {
function DeviceConfigs({
device,
canCollect,
credentials,
}: {
device: DeviceSummary
canCollect: boolean
credentials: Credential[]
}) {
const [versions, setVersions] = useState<ConfigVersion[]>([])
const [jobs, setJobs] = useState<ConfigJobRow[]>([])
const [open, setOpen] = useState<ConfigVersion | null>(null)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [policy, setPolicy] = useState<BackupPolicy | null>(null)
const [editPolicy, setEditPolicy] = useState(false)
const reload = useCallback(async () => {
try {
const [v, j] = await Promise.all([
const [v, j, pol] = await Promise.all([
api.listConfigs(device.id),
api.listConfigJobs(device.id).catch(() => [] as ConfigJobRow[]),
api.getBackupPolicy(device.id).catch(() => null),
])
setVersions(v)
setJobs(j)
setPolicy(pol)
setOpen((cur) => (cur && v.some((x) => x.id === cur.id) ? cur : v[0] ?? null))
setErr(null)
} catch (e) {
@ -177,9 +201,18 @@ function DeviceConfigs({ device, canCollect }: { device: DeviceSummary; canColle
{versions.length} версій
{versions[0] && ` · остання ${ago(versions[0].collected_at)} тому`}
</span>
<span className="ml-auto flex items-center gap-2">
<span className="text-xs text-slate-600">
{policy?.enabled
? `за розкладом ${policy.cron}`
: 'розклад не налаштовано'}
</span>
{canCollect && (
<Button onClick={() => setEditPolicy(true)}>Розклад</Button>
)}
</span>
{canCollect && (
<Button
className="ml-auto"
kind="primary"
disabled={busy || running}
onClick={async () => {
@ -217,6 +250,17 @@ function DeviceConfigs({ device, canCollect }: { device: DeviceSummary; canColle
</Card>
)}
{editPolicy && (
<BackupPolicyForm
device={device}
credentials={credentials}
onClose={() => {
setEditPolicy(false)
void reload()
}}
/>
)}
{loading ? (
<Spinner />
) : versions.length === 0 ? (

View file

@ -261,6 +261,25 @@ export interface DiffResult {
first?: boolean
}
export interface BackupPolicy {
device_id: string
profile_id?: string
credential_id?: string
enabled: boolean
cron: string
on_syslog: boolean
last_backup_at?: string
next_backup_at?: string
}
export interface NcmProfile {
id: string
key: string
name: string
vendor: string
is_builtin: boolean
}
export interface AgentSummary {
id: string
name: string