diff --git a/HISTORY.md b/HISTORY.md index 7f11e25..18e0578 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -2160,3 +2160,105 @@ YAML → compose і workflow розбираються, зл ``` Самі образи не збиралися: docker недоступний ні локально, ні на стенді. + +## Git-двигун NCM + +### Сховище + +`go-git`, чистий Go без cgo — статичний бінарник лишається статичним. +Голий репозиторій на тенанта, гілка на пристрій: +`refs/heads/device/`, файл `<пристрій>/<тип>.cfg`. + +Гілка за ідентифікатором, а не за іменем: пристрій перейменовують, і +історія не має від цього розсипатись на дві. Шлях навпаки за іменем — +у дереві його читають очима. Так було в коді до цього рівно навпаки, і +перше ж перейменування дало б порожню гілку замість історії. + +Голий репозиторій означає, що дерева доводиться складати руками: індексу +немає, а go-git дає для цього об'єкти, але не зручності worktree. +`putPath` рекурсивно перебудовує дерево по частинах шляху. + +Записи в дереві мусять бути впорядковані, причому каталог порівнюється +так, ніби його ім'я закінчується скісною рискою. Порушиш — той самий +вміст дасть інший хеш, а `git fsck` назве дерево пошкодженим. Саме тому +серед тестів є прогін справжнього `git fsck --strict`: go-git такий +об'єкт приймає й читає, а git — ні, і розійшлись би вони мовчки. + +Однаковий вміст нового коміту не створює. Інакше кожен нічний бекап +незмінного пристрою додавав би порожню зміну, і за місяць історія +складалась би з тридцяти однакових записів, серед яких треба шукати одну +справжню правку. + +Новий репозиторій отримує гілку `main` з поясненням розкладки: без неї +HEAD показує на ненароджену гілку, і `git fsck` та `git clone` про це +кажуть — виглядає як пошкодження, хоча все ціле. + +### Два сховища, і чому обидва + +Тіло конфігу лежить зашифрованим у `core.secrets` і відповідає на «який +конфіг зараз». Git відповідає на «що і коли змінилось за півроку». +Друге питання ставлять під час розбору аварії, і відповідь не має +залежати від того, чи не почистив таблицю ретеншен. + +Помилка Git не губить бекап: конфіг уже знято з пристрою, і викидати +його через проблему з диском — найдорожчий спосіб відреагувати. Версія +лягає в базу з контентним хешем, несправність видно в журналі. + +### netpulse-gitsync + +Переливає збережені версії в Git у хронологічному порядку, з часом збору +замість часу переливання — інакше вся історія злипається в одну хвилину. + +Потрібна двічі за життя інсталяції: коли версіювання вмикають на системі, +яка вже місяцями збирає конфіги, і коли диск із репозиторієм втрачено. +Друге дає корисну властивість: **репозиторій повністю відтворюється з +бази**, тож бекапити том із Git не обов'язково. Зворотне невірно, і саме +тому джерелом істини лишається дамп БД. + +### Чотири поламані тести, знайдені дорогою + +CI, який я написав минулим кроком, уперше запустив тести з DSN — і вони +не пройшли. Три причини, і лише одна виявилась моєю помилкою в коді. + +**Машинний токен не міг читати мапи.** ACL мап фільтрує за групами +користувача, а в токена користувача немає: порожній рядок ішов у запит +як `uuid`, і `/api/v1/maps` відповідав п'ятисоткою на кожен виклик із +токеном. Перевірка на NULL обов'язкова саме перевіркою, а не `COALESCE`: +`map_access_level` не `STRICT` і з NULL-користувачем чесно доходить до +`deny`. Токен не належить до груп, тож його межі задають scopes, які вже +перевірив обробник. + +**Тест ротації сесії перевіряв поведінку, яку я свідомо змінив.** Старий +refresh-токен тепер живе ще 30 секунд — це і є те виправлення, після +якого сторінка перестала просити логін при кожному перезавантаженні. +Тест переписано: у вікні повторний обмін має проходити, поза вікном — +ні. Щоб не чекати наживо, позначка відкликання відсувається в БД. + +**Тест підробленого токена був випадковим.** Він псував ОСТАННІЙ символ +підпису, а останній символ base64url несе лише 4 значущі біти з шести. +'A' і 'B' на цьому місці дають ті самі 32 байти, токен лишається дійсним +— і тест падав приблизно в кожному третьому запуску. Псуємо перший +символ підпису. + +**Тест входу без членства не знав про логіни.** `username` став +обов'язковим разом із входом за іменем, а тест вставляв користувача +напряму без нього. + +Урок: тест, який ніхто не запускає, з часом перевіряє не те, що +здається. CI з базою був потрібен не для майбутніх помилок, а щоб +побачити накопичені. + +### Перевірено наскрізно + +``` +gitstore 6 тестів, серед них git fsck --strict на дереві, зібраному вручну +netpulse-gitsync 2 наявні версії stand-host → 2 коміти, база оновлена + гілка device/371f89fc…, шлях stand-host/running.cfg +живий збір два бекапи QA-хоста з різним вмістом + → «конфіг прийнято змінився=true commit=d3b061017ce9…» + git log: два коміти, старіший — батько + git diff: -1 +2 рядки + API diff: ті самі рядки +git fsck --strict чисто на обох гілках +тести server ×2 і agent — зелені +``` diff --git a/ROADMAP.md b/ROADMAP.md index fe5b8c4..123a612 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,7 +24,7 @@ API віддає готове полотно з живими статусами, | Автовиявлення LLDP/CDP/ARP/FDB | ✅ | ✅ | | **Користувачі, ролі, вхід** | ✅ | ✅ | | **Шаблони опитування** | ❌ | ❌ | -| **NCM (збір конфігів)** | ✅ | ⚠️ збір працює, Git і розклад — ні | +| **NCM (збір конфігів)** | ✅ | ⚠️ збір, розклад і Git є; лишився тригер за Syslog | | **Керування зондом із UI** | ✅ | ⚠️ транспорт є | | **Алерти й сповіщення** | ✅ | ✅ | | **Мобільна адаптивність, PWA** | — | ⚠️ адаптив є, PWA немає | @@ -173,14 +173,19 @@ JSONPath, `discard unchanged`) — на сервері при записі: ін --- -## Етап 7. NCM — збір конфігів до кінця — ⚠️ наполовину, 2026-08-16 +## Етап 7. NCM — збір конфігів до кінця — ⚠️ майже, 2026-08-25 > **Зроблено:** агентський модуль SSH/Telnet, черга завдань, диспетчер, > вивантаження зі стисненням і дедуплікацією, візуальний diff у вебі, -> планувальник за cron. Перевірено наскрізно. +> планувальник за cron, Git-двигун на go-git, порівняння з довільною +> версією в UI, переливання історії командою `netpulse-gitsync`. +> Перевірено наскрізно. > -> **Лишилось:** тригер за Syslog, Git-двигун, порівняння з довільною -> версією в UI. +> **Лишилось:** тригер за Syslog. Він упирається не в тригер: приймача +> syslog немає взагалі — `ts.syslog` і `WriteLogs` у сховищі є, але на +> агенті немає модуля, який слухав би 514/udp, і транспорт до сервера +> не під'єднано. Це окрема робота розміром із власний етап, а не +> дописування умови до наявного коду. ## Етап 7 (початковий план) @@ -198,13 +203,13 @@ JSONPath, `discard unchanged`) — на сервері при записі: ін тригер за Syslog-подією (`on_syslog`, `%SYS-5-CONFIG_I`) — поле в схемі є, читати його нікому. -**Git-двигун.** `go-git` замість libgit2 — **чистий Go, без cgo**, що зберігає -статичний бінарник без залежностей. Комітить у `refs/heads/device/`, віддає -diff між ревізіями. Зараз `commit_sha` тимчасово містить hex контентного хеша, і -міняти доведеться лише джерело цього поля. +**Git-двигун.** ✅ `go-git`, чистий Go без cgo. Голий репозиторій на тенанта, +гілка `refs/heads/device/` на пристрій, файл `<пристрій>/<тип>.cfg`. +Однаковий вміст нового коміту не створює. `netpulse-gitsync` переливає вже +накопичену історію й відтворює втрачений репозиторій із бази. -**UI:** список версій, side-by-side diff, кнопка відкату (сутність -`ncm.rollbacks` із двоетапним погодженням уже є). +**UI:** ✅ список версій і порівняння з довільною попередньою. Кнопка відкату +лишилась (сутність `ncm.rollbacks` із двоетапним погодженням уже є). **Обсяг:** ~4–5 днів. diff --git a/deploy/README.md b/deploy/README.md index e7cb625..24875b0 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -15,6 +15,10 @@ | `collector` | gRPC-колектор, до якого підключаються зонди | 9443 | | `proxy` | Caddy: сертифікати, HTTPS, проксі до двох служб | 80/443/9443 | +Історія конфігів живе в Git на томі `git-data`, спільному для `api` й +`collector`: перший читає її для порівняння версій, другий пише під час +бекапу. + Назовні дивиться лише `proxy`. `api` і `collector` портів не публікують: до них ходять через нього. @@ -94,6 +98,16 @@ docker compose exec -T db \ Ключі зберігати **окремо від дампа** — інакше сенс шифрування секретів зникає: той, хто дістав бекап, дістав і ключ до нього. +Том `git-data` бекапити не обовʼязково: тіла конфігів лежать +зашифрованими в базі, і репозиторій повністю відтворюється з неї — + +```sh +docker compose run --rm api netpulse-gitsync +``` + +Зворотне невірно: з репозиторію базу не відновити. Тому джерелом істини +лишається дамп, а Git — похідне сховище, яке коштує один запуск команди. + ### Автоматично, щодня ```cron diff --git a/docker-compose.yml b/docker-compose.yml index 90e7506..8a8e611 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -82,6 +82,12 @@ services: environment: <<: *server-env NETPULSE_JWT_SECRET: ${NETPULSE_JWT_SECRET:?потрібен NETPULSE_JWT_SECRET — не менше 32 байтів} + # Історія конфігів у Git. Спільний том для API й колектора: + # колектор туди пише під час бекапу, API звідти читає для порівняння + # версій. Різні томи означали б, що половина історії видна одному + # процесу, а половина другому. + volumes: + - git-data:/var/lib/netpulse depends_on: migrate: condition: service_completed_successfully @@ -106,6 +112,12 @@ services: - -listen=:9443 - -insecure environment: *server-env + # Історія конфігів у Git. Спільний том для API й колектора: + # колектор туди пише під час бекапу, API звідти читає для порівняння + # версій. Різні томи означали б, що половина історії видна одному + # процесу, а половина другому. + volumes: + - git-data:/var/lib/netpulse depends_on: migrate: condition: service_completed_successfully @@ -165,6 +177,7 @@ services: volumes: db-data: + git-data: cache-data: caddy-data: caddy-config: diff --git a/server/cmd/netpulse-api/main.go b/server/cmd/netpulse-api/main.go index 60d204e..0582bd5 100644 --- a/server/cmd/netpulse-api/main.go +++ b/server/cmd/netpulse-api/main.go @@ -20,6 +20,7 @@ import ( "github.com/netpulse/netpulse/server/internal/alerting" "github.com/netpulse/netpulse/server/internal/auth" "github.com/netpulse/netpulse/server/internal/crypto" + "github.com/netpulse/netpulse/server/internal/gitstore" "github.com/netpulse/netpulse/server/internal/httpapi" "github.com/netpulse/netpulse/server/webui" "github.com/netpulse/netpulse/server/internal/store" @@ -51,6 +52,8 @@ func run() error { "як часто обчислювати правила алертів; 0 — не запускати движок") alertKeep = flag.Duration("alert-retention", 7*24*time.Hour, "скільки тримати закриті алерти до переносу в історію") + gitRoot = flag.String("git-root", envOr("NETPULSE_GIT_ROOT", "/var/lib/netpulse/git"), + "корінь сховища версій конфігів; порожньо — без Git") privateHooks = flag.Bool("allow-private-webhooks", os.Getenv("NETPULSE_ALLOW_PRIVATE_WEBHOOKS") == "1", "дозволити вебхуки на внутрішні адреси — для self-hosted інсталяцій") @@ -87,6 +90,14 @@ func run() error { return fmt.Errorf("-dek: %w", err) } + // Сховище версій конфігів. Спільний каталог для API й колектора: + // колектор туди пише під час бекапу, API звідти читає для diff. + // Різні машини потребують спільного тому — інакше половина історії + // буде видна одному процесу, а половина другому. + if *gitRoot != "" { + st.UseGit(gitstore.New(*gitRoot)) + } + api := httpapi.New(st, signer, log) // Зібраний інтерфейс, якщо він є в цій збірці. Порожній dist — diff --git a/server/cmd/netpulse-gitsync/main.go b/server/cmd/netpulse-gitsync/main.go new file mode 100644 index 0000000..5a4d900 --- /dev/null +++ b/server/cmd/netpulse-gitsync/main.go @@ -0,0 +1,133 @@ +// Команда netpulse-gitsync — переливання історії конфігів у Git. +// +// Потрібна двічі за життя інсталяції. +// +// Коли версіювання вмикають на системі, яка вже місяцями збирає конфіги: +// без переливання репозиторій починається з наступної зміни, і вся +// накопичена історія лишається невидимою саме тоді, коли її вперше +// захотіли подивитись. +// +// І коли диск із репозиторієм втрачено. Тіла конфігів лежать +// зашифрованими в базі, тож Git повністю відтворюваний з неї — +// зворотне невірно, і саме тому база лишається джерелом істини. +// +// netpulse-gitsync -dsn ... -dek np1= -git-root /var/lib/netpulse/git +// +// Повторний запуск безпечний: незмінений вміст нового коміту не створює. +package main + +import ( + "context" + "flag" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/netpulse/netpulse/server/internal/crypto" + "github.com/netpulse/netpulse/server/internal/gitstore" + "github.com/netpulse/netpulse/server/internal/store" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "netpulse-gitsync:", err) + os.Exit(1) + } +} + +func run() error { + var ( + dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL") + keys = flag.String("dek", os.Getenv("NETPULSE_DEK"), "ключі шифрування: key_id=[,...]") + gitRoot = flag.String("git-root", envOr("NETPULSE_GIT_ROOT", "/var/lib/netpulse/git"), "корінь сховища версій") + tenant = flag.String("tenant", "", "uuid тенанта; порожньо — усі") + dryRun = flag.Bool("dry-run", false, "лише показати, що буде записано") + quiet = flag.Bool("quiet", false, "без переліку версій") + ) + flag.Parse() + + if *dsn == "" { + return fmt.Errorf("не вказано -dsn") + } + if *gitRoot == "" { + return fmt.Errorf("не вказано -git-root") + } + + ring, err := crypto.ParseKeyring(*keys) + if err != nil { + return fmt.Errorf("-dek: %w", err) + } + if ring == nil { + return fmt.Errorf("не вказано -dek: тіла конфігів зашифровані, без ключа їх не прочитати") + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + st, err := store.New(ctx, *dsn) + if err != nil { + return fmt.Errorf("підключення до БД: %w", err) + } + defer st.Close() + st.UseGit(gitstore.New(*gitRoot)) + + tenants := []string{*tenant} + if *tenant == "" { + tenants, err = st.TenantIDs(ctx) + if err != nil { + return err + } + } + + var total store.GitSyncStat + for _, id := range tenants { + var onProgress func(string, string, time.Time, gitstore.Result) + if !*quiet { + onProgress = func(device, kind string, at time.Time, res gitstore.Result) { + mark := "+" + if res.Unchanged { + mark = "=" + } + fmt.Printf(" %s %s %s %s %s\n", + mark, at.Format("2006-01-02 15:04"), device, kind, short(res.CommitSHA)) + } + } + + fmt.Printf("тенант %s\n", id) + stat, err := st.SyncGit(ctx, id, ring, *dryRun, onProgress) + if err != nil { + return fmt.Errorf("тенант %s: %w", id, err) + } + total.Total += stat.Total + total.Written += stat.Written + total.Unchanged += stat.Unchanged + total.Skipped += stat.Skipped + } + + verb := "записано" + if *dryRun { + verb = "буде записано" + } + fmt.Printf("\nверсій %d, %s %d, без змін %d, пропущено %d\n", + total.Total, verb, total.Written, total.Unchanged, total.Skipped) + if total.Skipped > 0 { + fmt.Println("пропущені версії — це записи без збереженого тіла або з чужим ключем шифрування") + } + return nil +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func short(sha string) string { + if len(sha) > 12 { + return sha[:12] + } + return sha +} diff --git a/server/cmd/netpulse-server/main.go b/server/cmd/netpulse-server/main.go index 1b67d8d..ef623f8 100644 --- a/server/cmd/netpulse-server/main.go +++ b/server/cmd/netpulse-server/main.go @@ -18,6 +18,7 @@ import ( "time" "github.com/netpulse/netpulse/server/internal/crypto" + "github.com/netpulse/netpulse/server/internal/gitstore" "github.com/netpulse/netpulse/server/internal/grpcapi" "github.com/netpulse/netpulse/server/internal/store" npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1" @@ -45,6 +46,8 @@ func run() error { insecure = flag.Bool("insecure", os.Getenv("NETPULSE_INSECURE") == "1", "без TLS — лише локальний стенд") keysFlag = flag.String("dek", os.Getenv("NETPULSE_DEK"), "ключі шифрування: key_id=[,...]") logLevel = flag.String("log-level", envOr("NETPULSE_LOG_LEVEL", "info"), "debug|info|warn|error") + gitRoot = flag.String("git-root", envOr("NETPULSE_GIT_ROOT", "/var/lib/netpulse/git"), + "корінь сховища версій конфігів; порожньо — без Git") ) flag.Parse() @@ -68,6 +71,14 @@ func run() error { } defer st.Close() + // Сховище версій конфігів. Спільний каталог для API й колектора: + // колектор туди пише під час бекапу, API звідти читає для diff. + // Різні машини потребують спільного тому — інакше половина історії + // буде видна одному процесу, а половина другому. + if *gitRoot != "" { + st.UseGit(gitstore.New(*gitRoot)) + } + svc := grpcapi.New(st, ring, log) opts := []grpc.ServerOption{ diff --git a/server/go.mod b/server/go.mod index 37d14cb..abaca09 100644 --- a/server/go.mod +++ b/server/go.mod @@ -6,21 +6,39 @@ require ( github.com/coder/websocket v1.8.15 github.com/jackc/pgx/v5 v5.7.6 github.com/netpulse/netpulse/gen/go v0.0.0 - golang.org/x/crypto v0.51.0 - golang.org/x/term v0.43.0 + golang.org/x/crypto v0.53.0 + golang.org/x/term v0.44.0 google.golang.org/grpc v1.83.0 google.golang.org/protobuf v1.36.12 ) require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.9.0 // indirect + github.com/go-git/go-git/v5 v5.19.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/pjbgf/sha1cd v0.6.0 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.39.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect ) replace github.com/netpulse/netpulse/gen/go => ../gen/go diff --git a/server/go.sum b/server/go.sum index 883c6f3..8d0bdc7 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,14 +1,35 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= +github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= +github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY= +github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -23,13 +44,35 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= +github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= @@ -42,18 +85,41 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= @@ -63,6 +129,11 @@ google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4J google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server/internal/gitstore/store.go b/server/internal/gitstore/store.go new file mode 100644 index 0000000..c9359cf --- /dev/null +++ b/server/internal/gitstore/store.go @@ -0,0 +1,460 @@ +// Package gitstore — версіювання конфігів пристроїв у Git. +// +// Один голий репозиторій на тенанта, гілка на пристрій: +// refs/heads/device/. Гілка за ідентифікатором, а не за +// іменем: пристрій перейменовують, і історія не має від цього +// розсипатись на дві. +// +// Голий репозиторій, без робочого каталогу: конфіги й так лежать в +// об'єктах Git, а друга копія на диску подвоює місце й дає ще один шлях, +// з якого їх можна прочитати. +// +// Чому взагалі Git, коли тіла вже лежать зашифрованими в базі: база +// відповідає на «який конфіг зараз», Git — на «що і коли змінилось за +// півроку». Друге питання ставлять під час розбору аварії, і відповідь +// на нього не має залежати від того, чи не почистив таблицю ретеншен. +package gitstore + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/filemode" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/plumbing/storer" +) + +// Store — сховище репозиторіїв під спільним коренем. +type Store struct { + root string + + // Запис у Git не потоково-безпечний: два коміти в одну гілку + // одночасно дають гонку за посилання, і один із них тихо зникає. + // Блокування на репозиторій, а не одне на все: тенанти між собою + // не конкурують. + mu sync.Mutex + locks map[string]*sync.Mutex +} + +func New(root string) *Store { + return &Store{root: root, locks: map[string]*sync.Mutex{}} +} + +// Root — корінь сховища. Порожній означає вимкнене версіювання. +func (s *Store) Root() string { return s.root } + +func (s *Store) lockFor(path string) *sync.Mutex { + s.mu.Lock() + defer s.mu.Unlock() + m, ok := s.locks[path] + if !ok { + m = &sync.Mutex{} + s.locks[path] = m + } + return m +} + +// Commit — що покласти в репозиторій. +type Commit struct { + // Repo — шлях репозиторію відносно кореня, наприклад ".git". + Repo string + Branch string + Path string + Body []byte + + Message string + Author string + Email string + When time.Time +} + +// Result — координати записаного. +type Result struct { + CommitSHA string + BlobSHA string + // Unchanged — вміст за цим шляхом уже такий; нового коміту немає, + // повернуто наявний. + Unchanged bool +} + +var ErrDisabled = errors.New("версіювання в Git вимкнено") + +// Write кладе тіло за шляхом і повертає координати коміту. +// +// Повторний запис того самого вмісту не створює коміту: інакше кожен +// нічний бекап незмінного пристрою додавав би порожню зміну, і за місяць +// історія складалась би з тридцяти однакових записів, серед яких треба +// шукати одну справжню правку. +func (s *Store) Write(c Commit) (Result, error) { + if s.root == "" { + return Result{}, ErrDisabled + } + if c.Repo == "" || c.Branch == "" || c.Path == "" { + return Result{}, errors.New("gitstore: не задано репозиторій, гілку або шлях") + } + + full := filepath.Join(s.root, c.Repo) + lock := s.lockFor(full) + lock.Lock() + defer lock.Unlock() + + repo, err := open(full) + if err != nil { + return Result{}, err + } + st := repo.Storer + + blobHash, err := writeBlob(st, c.Body) + if err != nil { + return Result{}, err + } + + refName := plumbing.NewBranchReferenceName(c.Branch) + var ( + parents []plumbing.Hash + baseTree *object.Tree + ) + ref, err := repo.Reference(refName, true) + switch { + case err == nil: + parent, err := object.GetCommit(st, ref.Hash()) + if err != nil { + return Result{}, fmt.Errorf("gitstore: батьківський коміт: %w", err) + } + parents = []plumbing.Hash{parent.Hash} + baseTree, err = parent.Tree() + if err != nil { + return Result{}, fmt.Errorf("gitstore: дерево батька: %w", err) + } + + // Уже те саме? Тоді коміту немає. + if e, err := baseTree.FindEntry(c.Path); err == nil && e.Hash == blobHash { + return Result{ + CommitSHA: parent.Hash.String(), + BlobSHA: blobHash.String(), + Unchanged: true, + }, nil + } + case errors.Is(err, plumbing.ErrReferenceNotFound): + // Перший конфіг цього пристрою — гілки ще немає. + default: + return Result{}, fmt.Errorf("gitstore: читання гілки: %w", err) + } + + treeHash, err := putPath(st, baseTree, splitPath(c.Path), blobHash) + if err != nil { + return Result{}, err + } + + when := c.When + if when.IsZero() { + when = time.Now() + } + sig := object.Signature{ + Name: orDefault(c.Author, "NetPulse"), + Email: orDefault(c.Email, "netpulse@localhost"), + When: when, + } + + commit := &object.Commit{ + Author: sig, + Committer: sig, + Message: orDefault(c.Message, "оновлено "+c.Path), + TreeHash: treeHash, + ParentHashes: parents, + } + obj := st.NewEncodedObject() + if err := commit.Encode(obj); err != nil { + return Result{}, err + } + commitHash, err := st.SetEncodedObject(obj) + if err != nil { + return Result{}, err + } + + if err := st.SetReference(plumbing.NewHashReference(refName, commitHash)); err != nil { + return Result{}, fmt.Errorf("gitstore: оновлення гілки: %w", err) + } + + return Result{CommitSHA: commitHash.String(), BlobSHA: blobHash.String()}, nil +} + +// Read повертає вміст файлу на момент коміту. +func (s *Store) Read(repoName, commitSHA, path string) ([]byte, error) { + if s.root == "" { + return nil, ErrDisabled + } + repo, err := open(filepath.Join(s.root, repoName)) + if err != nil { + return nil, err + } + commit, err := object.GetCommit(repo.Storer, plumbing.NewHash(commitSHA)) + if err != nil { + return nil, fmt.Errorf("gitstore: коміт %s: %w", short(commitSHA), err) + } + f, err := commit.File(path) + if err != nil { + return nil, fmt.Errorf("gitstore: %s у коміті %s: %w", path, short(commitSHA), err) + } + r, err := f.Blob.Reader() + if err != nil { + return nil, err + } + defer r.Close() + return io.ReadAll(r) +} + +// Entry — рядок історії гілки. +type Entry struct { + CommitSHA string + Message string + Author string + When time.Time +} + +// History повертає коміти гілки, новіші першими. +// +// Обхід лише за першим батьком: злиттів тут не буває — гілка пристрою +// лінійна за побудовою, її пише один процес під блокуванням. +func (s *Store) History(repoName, branch string, limit int) ([]Entry, error) { + if s.root == "" { + return nil, ErrDisabled + } + repo, err := open(filepath.Join(s.root, repoName)) + if err != nil { + return nil, err + } + ref, err := repo.Reference(plumbing.NewBranchReferenceName(branch), true) + if err != nil { + if errors.Is(err, plumbing.ErrReferenceNotFound) { + return nil, nil + } + return nil, err + } + + var out []Entry + hash := ref.Hash() + for { + commit, err := object.GetCommit(repo.Storer, hash) + if err != nil { + return out, err + } + out = append(out, Entry{ + CommitSHA: commit.Hash.String(), + Message: commit.Message, + Author: commit.Author.Name, + When: commit.Author.When, + }) + if (limit > 0 && len(out) >= limit) || len(commit.ParentHashes) == 0 { + return out, nil + } + hash = commit.ParentHashes[0] + } +} + +// --- нутрощі -------------------------------------------------------- + +func open(path string) (*git.Repository, error) { + repo, err := git.PlainOpen(path) + if err == nil { + return repo, nil + } + if !errors.Is(err, git.ErrRepositoryNotExists) { + return nil, fmt.Errorf("gitstore: відкриття %s: %w", path, err) + } + // 0700: у конфігах живуть ключі й хеші паролів, і Git тримає їх + // відкритим текстом у своїх об'єктах. Читати їх має право лише той + // користувач, під яким працює сервер. + if err := os.MkdirAll(path, 0o700); err != nil { + return nil, err + } + repo, err = git.PlainInit(path, true) + if err != nil { + return nil, fmt.Errorf("gitstore: створення %s: %w", path, err) + } + if err := seed(repo); err != nil { + return nil, err + } + return repo, nil +} + +// seed кладе в новий репозиторій гілку main з поясненням розкладки. +// +// Без неї HEAD свіжого репозиторію показує на ненароджену гілку, і +// `git fsck` та `git clone` про це кажуть — виглядає як пошкодження, +// хоча все ціле. Заразом той, хто склонує репозиторій руками, бачить +// пояснення, а не порожній каталог. +func seed(repo *git.Repository) error { + const readme = `# Конфіги пристроїв NetPulse + +Гілка на пристрій: ` + "`refs/heads/device/`" + `. +Усередині гілки — файли на кожен зріз: ` + "`<пристрій>/<тип>.cfg`" + `. + +Ідентифікатор, а не ім'я, у назві гілки: пристрій перейменовують, і +історія не має від цього розсипатись на дві. + +Репозиторій пише NetPulse. Правки руками наступний бекап не помітить: +сервер порівнює вміст із тим, що записав сам. +` + + st := repo.Storer + blob, err := writeBlob(st, []byte(readme)) + if err != nil { + return err + } + tree, err := putPath(st, nil, []string{"README.md"}, blob) + if err != nil { + return err + } + + sig := object.Signature{Name: "NetPulse", Email: "netpulse@localhost", When: time.Now()} + commit := &object.Commit{Author: sig, Committer: sig, Message: "розкладка репозиторію", TreeHash: tree} + obj := st.NewEncodedObject() + if err := commit.Encode(obj); err != nil { + return err + } + head, err := st.SetEncodedObject(obj) + if err != nil { + return err + } + + main := plumbing.NewBranchReferenceName("main") + if err := st.SetReference(plumbing.NewHashReference(main, head)); err != nil { + return err + } + return st.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, main)) +} + +func writeBlob(st storer.EncodedObjectStorer, body []byte) (plumbing.Hash, error) { + obj := st.NewEncodedObject() + obj.SetType(plumbing.BlobObject) + obj.SetSize(int64(len(body))) + w, err := obj.Writer() + if err != nil { + return plumbing.ZeroHash, err + } + if _, err := w.Write(body); err != nil { + w.Close() + return plumbing.ZeroHash, err + } + if err := w.Close(); err != nil { + return plumbing.ZeroHash, err + } + return st.SetEncodedObject(obj) +} + +// putPath кладе blob за шляхом, зберігаючи решту дерева. +// +// Рекурсія по частинах шляху: на кожному рівні береться наявне дерево, +// у ньому заміняється один запис, і результат стає записом рівнем вище. +// Голий репозиторій не має індексу, тож дерева доводиться складати +// самому — go-git дає для цього об'єкти, але не зручності worktree. +func putPath( + st storer.EncodedObjectStorer, + base *object.Tree, + parts []string, + blob plumbing.Hash, +) (plumbing.Hash, error) { + if len(parts) == 0 { + return plumbing.ZeroHash, errors.New("gitstore: порожній шлях") + } + + var entries []object.TreeEntry + if base != nil { + entries = append(entries, base.Entries...) + } + + name := parts[0] + entry := object.TreeEntry{Name: name} + + if len(parts) == 1 { + entry.Mode = filemode.Regular + entry.Hash = blob + } else { + var sub *object.Tree + if base != nil { + for _, e := range base.Entries { + if e.Name == name && e.Mode == filemode.Dir { + t, err := object.GetTree(st, e.Hash) + if err != nil { + return plumbing.ZeroHash, err + } + sub = t + break + } + } + } + h, err := putPath(st, sub, parts[1:], blob) + if err != nil { + return plumbing.ZeroHash, err + } + entry.Mode = filemode.Dir + entry.Hash = h + } + + replaced := false + for i := range entries { + if entries[i].Name == name { + entries[i] = entry + replaced = true + break + } + } + if !replaced { + entries = append(entries, entry) + } + + // Git вимагає впорядкованих записів дерева: той самий вміст із + // іншим порядком дає інший хеш, а `git fsck` називає таке дерево + // пошкодженим. + sort.Slice(entries, func(i, j int) bool { return sortKey(entries[i]) < sortKey(entries[j]) }) + + tree := &object.Tree{Entries: entries} + obj := st.NewEncodedObject() + if err := tree.Encode(obj); err != nil { + return plumbing.ZeroHash, err + } + return st.SetEncodedObject(obj) +} + +// sortKey — правило впорядкування записів дерева в Git: каталог +// порівнюється так, ніби його ім'я закінчується скісною рискою. +func sortKey(e object.TreeEntry) string { + if e.Mode == filemode.Dir { + return e.Name + "/" + } + return e.Name +} + +func splitPath(p string) []string { + var out []string + for _, part := range strings.Split(p, "/") { + if part != "" && part != "." && part != ".." { + out = append(out, part) + } + } + return out +} + +func orDefault(v, def string) string { + if strings.TrimSpace(v) == "" { + return def + } + return v +} + +func short(sha string) string { + if len(sha) > 8 { + return sha[:8] + } + return sha +} diff --git a/server/internal/gitstore/store_test.go b/server/internal/gitstore/store_test.go new file mode 100644 index 0000000..ef6d84b --- /dev/null +++ b/server/internal/gitstore/store_test.go @@ -0,0 +1,140 @@ +package gitstore + +import ( + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func write(t *testing.T, s *Store, branch, path, body string) Result { + t.Helper() + r, err := s.Write(Commit{ + Repo: "t.git", + Branch: branch, + Path: path, + Body: []byte(body), + Message: "тест", + Author: "QA", + Email: "qa@netpulse.local", + When: time.Date(2026, 8, 25, 12, 0, 0, 0, time.UTC), + }) + if err != nil { + t.Fatalf("запис: %v", err) + } + return r +} + +func TestWriteReadHistory(t *testing.T) { + s := New(t.TempDir()) + + first := write(t, s, "device/d1", "sw-01/running.cfg", "hostname sw-01\n") + if first.Unchanged { + t.Fatal("перший запис не може бути незміненим") + } + if len(first.CommitSHA) != 40 { + t.Fatalf("commit sha не схожий на git: %q", first.CommitSHA) + } + + // Той самий вміст не створює коміту — інакше історія заростає + // порожніми записами від нічних бекапів. + same := write(t, s, "device/d1", "sw-01/running.cfg", "hostname sw-01\n") + if !same.Unchanged { + t.Fatal("повторний запис того самого вмісту дав новий коміт") + } + if same.CommitSHA != first.CommitSHA { + t.Fatalf("незмінений запис змінив коміт: %s → %s", first.CommitSHA, same.CommitSHA) + } + + second := write(t, s, "device/d1", "sw-01/running.cfg", "hostname sw-01\nntp server 1.1.1.1\n") + if second.CommitSHA == first.CommitSHA { + t.Fatal("зміна вмісту не дала нового коміту") + } + + body, err := s.Read("t.git", first.CommitSHA, "sw-01/running.cfg") + if err != nil { + t.Fatalf("читання першої версії: %v", err) + } + if string(body) != "hostname sw-01\n" { + t.Fatalf("перша версія зіпсована: %q", body) + } + + hist, err := s.History("t.git", "device/d1", 0) + if err != nil { + t.Fatalf("історія: %v", err) + } + if len(hist) != 2 { + t.Fatalf("очікував 2 коміти, маю %d", len(hist)) + } + if hist[0].CommitSHA != second.CommitSHA { + t.Fatal("історія не починається з найновішого") + } + if hist[0].Author != "QA" { + t.Fatalf("автор загубився: %q", hist[0].Author) + } +} + +// Гілки різних пристроїв не мають бачити одна одну: інакше історія +// одного комутатора виявиться перемішаною з чужою. +func TestBranchesIndependent(t *testing.T) { + s := New(t.TempDir()) + + write(t, s, "device/d1", "sw-01/running.cfg", "a\n") + write(t, s, "device/d2", "sw-02/running.cfg", "b\n") + write(t, s, "device/d1", "sw-01/running.cfg", "a2\n") + + h1, _ := s.History("t.git", "device/d1", 0) + h2, _ := s.History("t.git", "device/d2", 0) + if len(h1) != 2 || len(h2) != 1 { + t.Fatalf("гілки перемішались: d1=%d d2=%d", len(h1), len(h2)) + } +} + +// Кілька зрізів одного пристрою живуть в одній гілці різними файлами. +func TestSeveralConfigTypes(t *testing.T) { + s := New(t.TempDir()) + + write(t, s, "device/d1", "sw-01/running.cfg", "running\n") + last := write(t, s, "device/d1", "sw-01/startup.cfg", "startup\n") + + running, err := s.Read("t.git", last.CommitSHA, "sw-01/running.cfg") + if err != nil { + t.Fatalf("running після додавання startup: %v", err) + } + if string(running) != "running\n" { + t.Fatalf("сусідній файл затерто: %q", running) + } +} + +// Дерева, зібрані вручну, мусять бути коректними з погляду самого git. +// Це єдина перевірка, яка ловить неправильний порядок записів у дереві: +// go-git такий об'єкт приймає й читає, а git — ні. +func TestFsckClean(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git не знайдено") + } + dir := t.TempDir() + s := New(dir) + + write(t, s, "device/d1", "sw-01/running.cfg", "a\n") + write(t, s, "device/d1", "sw-01/startup.cfg", "b\n") + write(t, s, "device/d1", "kyiv/dc1/sw-01/vlan.cfg", "c\n") + write(t, s, "device/d1", "sw-01/running.cfg", "a2\n") + + out, err := exec.Command("git", "--git-dir", filepath.Join(dir, "t.git"), + "fsck", "--strict").CombinedOutput() + if err != nil { + t.Fatalf("git fsck: %v\n%s", err, out) + } + if s := strings.TrimSpace(string(out)); s != "" { + t.Fatalf("git fsck поскаржився:\n%s", s) + } +} + +func TestDisabled(t *testing.T) { + s := New("") + if _, err := s.Write(Commit{Repo: "t.git", Branch: "b", Path: "p", Body: []byte("x")}); err == nil { + t.Fatal("порожній корінь мав дати помилку") + } +} diff --git a/server/internal/httpapi/auth_test.go b/server/internal/httpapi/auth_test.go index 8eeb8df..d9a3bb9 100644 --- a/server/internal/httpapi/auth_test.go +++ b/server/internal/httpapi/auth_test.go @@ -5,6 +5,9 @@ import ( "net/http" "strings" "testing" + "time" + + "github.com/netpulse/netpulse/server/internal/store" ) // seedUser заводить людину з паролем і роллю в тенанті фікстури. @@ -190,13 +193,22 @@ func TestForgedTokenRejected(t *testing.T) { f.seedUser(t, "user@example.com", "correct-horse-battery", "admin") _, out, _ := f.login(t, "user@example.com", "correct-horse-battery") - // Псуємо останній символ підпису. - bad := out.AccessToken[:len(out.AccessToken)-1] - if out.AccessToken[len(out.AccessToken)-1] == 'A' { - bad += "B" - } else { - bad += "A" + // Псуємо ПЕРШИЙ символ підпису, а не останній. + // + // Останній символ base64url несе лише 4 значущі біти з шести, а два + // зайві декодер відкидає. Тому 'A' і 'B' на останньому місці дають + // ті самі 32 байти підпису, токен лишається дійсним, і тест падає + // приблизно в кожному третьому запуску — залежно від того, який + // символ випав цього разу. + cut := strings.LastIndexByte(out.AccessToken, '.') + if cut < 0 || cut+1 >= len(out.AccessToken) { + t.Fatalf("токен без підпису: %q", out.AccessToken) } + repl := byte('A') + if out.AccessToken[cut+1] == 'A' { + repl = 'B' + } + bad := out.AccessToken[:cut+1] + string(repl) + out.AccessToken[cut+2:] if code, _ := f.get(t, "/api/v1/maps", bad); code != http.StatusUnauthorized { t.Fatalf("підроблений токен прийнято: код %d", code) @@ -235,10 +247,31 @@ func TestRefreshRotatesSession(t *testing.T) { t.Fatal("refresh-токен не змінився — ротації немає") } - // Старий токен має померти: викрадений спрацює щонайбільше раз, - // і законний власник одразу побачить відмову. + // Щойно обміняний токен ще працює — і це навмисно. + // + // Дві вкладки, повторний запит після обриву й подвійний ефект React + // дають два обміни поспіль з одного браузера. Без вікна вони вбивали + // одне одного, і виглядало це як «просить логін після кожного + // оновлення сторінки». + code, _, third := call(refresh) + if code != http.StatusOK { + t.Fatalf("повторний обмін у вікні відхилено: код %d", code) + } + if third == nil || third.Value == second.Value { + t.Fatal("повторний обмін не видав нового токена") + } + + // А от давній обмін — це вже не гонка, а повтор перехопленого + // токена. Відсуваємо позначку відкликання за межі вікна замість + // чекати наживо. + if _, err := f.pool.Exec(f.ctx, ` + UPDATE core.sessions SET revoked_at = now() - $1::interval + WHERE revoked_at IS NOT NULL + `, (store.RotationGrace + time.Minute).String()); err != nil { + t.Fatalf("зсув часу відкликання: %v", err) + } if code, _, _ := call(refresh); code != http.StatusUnauthorized { - t.Fatalf("старий refresh-токен усе ще діє: код %d", code) + t.Fatalf("старий refresh-токен діє й поза вікном: код %d", code) } } @@ -275,11 +308,11 @@ func TestLogoutRevokesSession(t *testing.T) { func TestLoginWithoutMembership(t *testing.T) { f := setup(t) - hash := "" // створюємо користувача напряму, без членства - _ = hash + // Логін обов'язковий: вхід за іменем користувача з'явився пізніше + // за цей тест, і колонка стала NOT NULL. if _, err := f.pool.Exec(f.ctx, ` - INSERT INTO core.users (email, password_hash, email_verified_at) - VALUES ('orphan@example.com', $1, now()) + INSERT INTO core.users (username, email, password_hash, email_verified_at) + VALUES ('orphan', 'orphan@example.com', $1, now()) `, mustHash(t, "correct-horse-battery")); err != nil { t.Fatalf("seed: %v", err) } diff --git a/server/internal/store/map_access.go b/server/internal/store/map_access.go index 921f6bb..0e059b9 100644 --- a/server/internal/store/map_access.go +++ b/server/internal/store/map_access.go @@ -19,7 +19,15 @@ type MapPermission struct { // maps:read»: у типовій інсталяції мапа одна на всіх, і вимагати // налаштувати доступ до неї означало б зробити типовий випадок // найдовшим. +// +// Порожній userID — це машинний токен, а не анонім. Токен не належить +// до жодної групи, тож груповий ACL до нього не застосовний: його межі +// задають scopes, які перевіряє обробник. Повертати йому «deny» означало +// б зламати інтеграції першою ж налаштованою групою на мапі. func (s *Store) MapAccess(ctx context.Context, tenantID, userID, mapID string) (string, error) { + if userID == "" { + return "write", nil + } var level string err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { return tx.QueryRow(ctx, diff --git a/server/internal/store/maps.go b/server/internal/store/maps.go index 158f84a..94a9447 100644 --- a/server/internal/store/maps.go +++ b/server/internal/store/maps.go @@ -134,12 +134,23 @@ func (s *Store) ListMaps(ctx context.Context, tenantID, userID string) ([]MapSum COALESCE(m.site_id::text, ''), m.is_default, m.revision, m.updated_at, (SELECT count(*) FROM topo.map_nodes n WHERE n.map_id = m.id), (SELECT count(*) FROM topo.map_edges e WHERE e.map_id = m.id), - topo.map_access_level($2, m.id)::text + -- Порожній користувач — машинний токен: групового + -- ACL до нього не застосувати, межі задають scopes, + -- які вже перевірив обробник. + -- + -- Перевірка на NULL обов'язкова, а не COALESCE: + -- map_access_level не STRICT і з NULL-користувачем + -- чесно доходить до 'deny'. Порожній рядок замість + -- NULL ламав запит зовсім — '' не приводиться до + -- uuid, і список мап відповідав п'ятисоткою на + -- кожен виклик із токеном. + CASE WHEN $2::uuid IS NULL THEN 'write'::core.access_level + ELSE topo.map_access_level($2, m.id) END::text FROM topo.maps m WHERE m.tenant_id = $1 AND m.deleted_at IS NULL - AND topo.map_access_level($2, m.id) <> 'deny' + AND ($2::uuid IS NULL OR topo.map_access_level($2, m.id) <> 'deny') ORDER BY m.is_default DESC, m.name - `, tenantID, userID) + `, tenantID, nullUUID(userID)) if err != nil { return err } diff --git a/server/internal/store/ncm.go b/server/internal/store/ncm.go index 9febe21..3441487 100644 --- a/server/internal/store/ncm.go +++ b/server/internal/store/ncm.go @@ -6,10 +6,12 @@ import ( "encoding/hex" "errors" "fmt" + "log/slog" "strings" "github.com/jackc/pgx/v5" "github.com/netpulse/netpulse/server/internal/crypto" + "github.com/netpulse/netpulse/server/internal/gitstore" ) // ConfigSubmission — зібраний із чанків конфіг. @@ -34,14 +36,17 @@ type ConfigOutcome struct { var ErrChecksumMismatch = errors.New("контрольна сума не збіглася") // StoreConfig приймає конфіг: звіряє суму, визначає, чи є зміна, -// і зберігає нову версію. +// зберігає нову версію й кладе її в Git. // -// СТАН РЕАЛІЗАЦІЇ: Git-двигун (libgit2) ще не підключено. Тіло -// зберігається зашифрованим у core.secrets, а commit_sha тимчасово -// містить hex контентного хеша. Усе інше — дедуплікація, підрахунок -// рядків, ланцюжок prev_config_id — працює вже зараз, тому diff між -// версіями будується без Git. Коли двигун з'явиться, зміниться лише -// джерело commit_sha. +// Два сховища замість одного — свідомо. Тіло лежить зашифрованим у +// core.secrets і відповідає на «який конфіг зараз»; Git відповідає на +// «що і коли змінилось за півроку». Друге питання ставлять під час +// розбору аварії, і відповідь не має залежати від того, чи не почистив +// таблицю ретеншен. +// +// Без під'єднаного Git (UseGit не викликали) працює як і раніше: +// commit_sha містить hex контентного хеша. Це робочий режим для +// інсталяцій без окремого диска під історію. func (s *Store) StoreConfig(ctx context.Context, a *Agent, sub ConfigSubmission, ring *crypto.Keyring) (ConfigOutcome, error) { sum := sha256.Sum256(sub.Body) if len(sub.ClaimedHash) > 0 && !equalBytes(sum[:], sub.ClaimedHash) { @@ -132,7 +137,34 @@ func (s *Store) StoreConfig(ctx context.Context, a *Agent, sub ConfigSubmission, return fmt.Errorf("пристрій %s: %w", sub.DeviceID, err) } - path := fmt.Sprintf("%s/%s.cfg", deviceName, configType) + // Шлях за іменем, гілка за ідентифікатором. Ім'я в дереві + // читабельне й змінюється разом із пристроєм; гілка мусить + // пережити перейменування, інакше історія розсипається на дві. + path := fmt.Sprintf("%s/%s.cfg", sanitizePath(deviceName), configType) + branch := "device/" + sub.DeviceID + + commitSHA, blobSHA := hexSum, hexSum + if s.git != nil { + res, gerr := s.git.Write(gitstore.Commit{ + Repo: a.TenantID + ".git", + Branch: branch, + Path: path, + Body: sub.Body, + Message: fmt.Sprintf("%s: %s", deviceName, configType), + Author: "NetPulse", + Email: "netpulse@localhost", + }) + if gerr != nil { + // Помилка Git не має губити бекап: конфіг уже зібрано з + // пристрою, і викинути його через проблему з диском — + // найдорожчий спосіб відреагувати. Версія лягає в базу + // з контентним хешем, а несправність видно в журналі. + slog.Error("git: конфіг не записано, лишаємо тільки в базі", + "device", sub.DeviceID, "error", gerr) + } else { + commitSHA, blobSHA = res.CommitSHA, res.BlobSHA + } + } var prevArg any if hasPrev { @@ -146,8 +178,8 @@ func (s *Store) StoreConfig(ctx context.Context, a *Agent, sub ConfigSubmission, prev_config_id, is_change) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13::uuid,$14) RETURNING id::text - `, a.TenantID, sub.DeviceID, repoID, hexSum, hexSum, - "device/"+deviceName, path, configType, + `, a.TenantID, sub.DeviceID, repoID, commitSHA, blobSHA, + branch, path, configType, len(sub.Body), lines, sum[:], secretID, prevArg, hasPrev).Scan(&out.ConfigID) if err != nil { return fmt.Errorf("вставка версії конфігу: %w", err) @@ -162,7 +194,7 @@ func (s *Store) StoreConfig(ctx context.Context, a *Agent, sub ConfigSubmission, } out.Accepted = true - out.CommitSHA = hexSum + out.CommitSHA = commitSHA return nil }) @@ -180,3 +212,27 @@ func equalBytes(a, b []byte) bool { } return true } + +// sanitizePath робить з імені пристрою придатну частину шляху. +// +// Імена заводять люди: там трапляються скісні риски, пробіли й крапки +// на початку. Скісна риска створила б зайвий рівень дерева, а ".." — +// вийшла б за межі репозиторію. +func sanitizePath(name string) string { + var b strings.Builder + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + b.WriteRune(r) + case r == '-' || r == '_' || r == '.': + b.WriteRune(r) + default: + b.WriteRune('-') + } + } + out := strings.Trim(b.String(), ".-") + if out == "" { + return "device" + } + return out +} diff --git a/server/internal/store/ncm_git.go b/server/internal/store/ncm_git.go new file mode 100644 index 0000000..3677124 --- /dev/null +++ b/server/internal/store/ncm_git.go @@ -0,0 +1,164 @@ +package store + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/netpulse/netpulse/server/internal/crypto" + "github.com/netpulse/netpulse/server/internal/gitstore" +) + +// GitSyncStat — підсумок переливання історії в Git. +type GitSyncStat struct { + Total int + Written int + Unchanged int + Skipped int +} + +// SyncGit переливає збережені версії конфігів у Git. +// +// Потрібна двічі. Перший раз — коли версіювання вмикають на інсталяції, +// яка вже місяцями збирає конфіги: без цього репозиторій починається з +// наступної зміни, і вся накопичена історія лишається невидимою. +// Другий — коли диск із репозиторієм втрачено. Тіла лежать зашифровані +// в базі, тож Git тут вторинний і повністю відтворюваний; зворотне +// невірно, і саме тому база лишається джерелом істини. +// +// Версії йдуть у хронологічному порядку — інакше в гілці пристрою +// новіший конфіг став би батьком старішого, і історія читалась би +// навиворіт. +func (s *Store) SyncGit( + ctx context.Context, + tenantID string, + ring *crypto.Keyring, + dryRun bool, + onProgress func(deviceName, configType string, at time.Time, res gitstore.Result), +) (GitSyncStat, error) { + var stat GitSyncStat + if s.git == nil { + return stat, gitstore.ErrDisabled + } + + type row struct { + id string + deviceID string + deviceName string + configType string + collected time.Time + } + + var rows []row + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + r, err := tx.Query(ctx, ` + SELECT c.id::text, c.device_id::text, d.name, c.config_type, c.collected_at + FROM ncm.configs c + JOIN inv.devices d ON d.id = c.device_id + WHERE c.tenant_id = $1 + ORDER BY c.device_id, c.config_type, c.collected_at + `, tenantID) + if err != nil { + return err + } + defer r.Close() + for r.Next() { + var x row + if err := r.Scan(&x.id, &x.deviceID, &x.deviceName, &x.configType, &x.collected); err != nil { + return err + } + rows = append(rows, x) + } + return r.Err() + }) + if err != nil { + return stat, err + } + stat.Total = len(rows) + + for _, x := range rows { + body, _, err := s.ConfigBody(ctx, tenantID, x.id, ring) + if err != nil { + // Версія без тіла — не привід зупиняти переливання решти: + // одна зіпсована стрічка не має коштувати всієї історії. + stat.Skipped++ + continue + } + + path := fmt.Sprintf("%s/%s.cfg", sanitizePath(x.deviceName), x.configType) + branch := "device/" + x.deviceID + + if dryRun { + stat.Written++ + if onProgress != nil { + onProgress(x.deviceName, x.configType, x.collected, gitstore.Result{}) + } + continue + } + + res, err := s.git.Write(gitstore.Commit{ + Repo: tenantID + ".git", + Branch: branch, + Path: path, + Body: []byte(body), + Message: fmt.Sprintf("%s: %s", x.deviceName, x.configType), + Author: "NetPulse", + Email: "netpulse@localhost", + // Час збору, а не час переливання: інакше вся історія + // злипається в одну хвилину й перестає відповідати на + // питання «коли це змінилось». + When: x.collected, + }) + if err != nil { + return stat, fmt.Errorf("версія %s: %w", x.id, err) + } + + if res.Unchanged { + stat.Unchanged++ + } else { + stat.Written++ + } + if onProgress != nil { + onProgress(x.deviceName, x.configType, x.collected, res) + } + + // Координати в базі мають вести на справжній об'єкт Git, інакше + // відновлення виглядатиме зробленим, а посилання лишаться + // зламаними. + if err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + UPDATE ncm.configs + SET commit_sha = $3, blob_sha = $4, branch = $5, path = $6 + WHERE tenant_id = $1 AND id = $2 + `, tenantID, x.id, res.CommitSHA, res.BlobSHA, branch, path) + return err + }); err != nil { + return stat, err + } + } + + return stat, nil +} + +// TenantIDs — усі живі тенанти. Потрібно командам, які працюють над +// усією інсталяцією, а не над одним кабінетом. +func (s *Store) TenantIDs(ctx context.Context) ([]string, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id::text FROM core.tenants WHERE deleted_at IS NULL ORDER BY created_at + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} diff --git a/server/internal/store/store.go b/server/internal/store/store.go index 9664352..d4d3557 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -19,12 +19,24 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + "github.com/netpulse/netpulse/server/internal/gitstore" ) type Store struct { pool *pgxpool.Pool + + // Версіювання конфігів. Не обов'язкове: без нього NCM працює як і + // раніше — тіла лежать зашифрованими в базі, а замість sha коміту + // пишеться хеш вмісту. Порожнє значення тут — робочий стан, а не + // недороблена ініціалізація. + git *gitstore.Store } +// UseGit під'єднує сховище версій. Окремим викликом, а не параметром +// New: конструктор потрібен і там, де Git не потрібен зовсім, — у +// міграторі, в утилітах командного рядка, у тестах. +func (s *Store) UseGit(g *gitstore.Store) { s.git = g } + func New(ctx context.Context, dsn string) (*Store, error) { cfg, err := pgxpool.ParseConfig(dsn) if err != nil { diff --git a/web/src/pages/ConfigsPage.tsx b/web/src/pages/ConfigsPage.tsx index 6c1f08e..6004c89 100644 --- a/web/src/pages/ConfigsPage.tsx +++ b/web/src/pages/ConfigsPage.tsx @@ -313,28 +313,48 @@ function DeviceConfigs({ - {open && } + {open && } )} ) } -function DiffView({ version }: { version: ConfigVersion }) { +function DiffView({ + version, + versions, +}: { + version: ConfigVersion + versions: ConfigVersion[] +}) { const [diff, setDiff] = useState(null) const [body, setBody] = useState(null) const [mode, setMode] = useState<'diff' | 'full'>('diff') const [err, setErr] = useState(null) + // Порожньо — з попередньою версією. Саме це питання ставлять у 90% + // випадків, тож воно й лишається типовим. + const [base, setBase] = useState('') + + // Порівнювати можна лише з тим, що було раніше: diff «уперед» показав + // би зміни задом наперед і читався б як їхня протилежність. + const earlier = versions.filter((v) => v.collected_at < version.collected_at) + + // Вибрана база могла зникнути при переході на іншу версію — тоді + // повертаємось до типового порівняння, а не показуємо чужий diff. + useEffect(() => { + setBase((b) => (b && earlier.some((v) => v.id === b) ? b : '')) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [version.id]) useEffect(() => { setDiff(null) setBody(null) setErr(null) api - .diffConfig(version.id) + .diffConfig(version.id, base || undefined) .then(setDiff) .catch((e) => setErr(e instanceof Error ? e.message : String(e))) - }, [version.id]) + }, [version.id, base]) useEffect(() => { if (mode !== 'full' || body !== null) return @@ -354,6 +374,24 @@ function DiffView({ version }: { version: ConfigVersion }) { −{diff.lines_removed} )} + {mode === 'diff' && earlier.length > 0 && ( + + )}