Git-двигун NCM: версіювання конфігів, переливання історії, довільне порівняння
go-git, чистий Go без cgo. Голий репозиторій на тенанта, гілка на пристрій за ідентифікатором (ім'я змінюють, історія не має від цього розсипатись), файл за іменем. Однаковий вміст нового коміту не створює. netpulse-gitsync переливає накопичену історію й відтворює втрачений репозиторій із бази — тіла конфігів там і так лежать зашифрованими. У вебі з'явився вибір версії, з якою порівнювати: сервер це вмів (?from=), інтерфейс — ні. Дорогою виправлено чотири тести, які CI запустив уперше з базою. Серед них справжня помилка: машинний токен не міг читати мапи — ACL мап отримував порожній рядок замість uuid і давав 500. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
a0af97c97c
commit
65b37a28ff
18 changed files with 1352 additions and 48 deletions
102
HISTORY.md
102
HISTORY.md
|
|
@ -2160,3 +2160,105 @@ YAML → compose і workflow розбираються, зл
|
||||||
```
|
```
|
||||||
|
|
||||||
Самі образи не збиралися: docker недоступний ні локально, ні на стенді.
|
Самі образи не збиралися: docker недоступний ні локально, ні на стенді.
|
||||||
|
|
||||||
|
## Git-двигун NCM
|
||||||
|
|
||||||
|
### Сховище
|
||||||
|
|
||||||
|
`go-git`, чистий Go без cgo — статичний бінарник лишається статичним.
|
||||||
|
Голий репозиторій на тенанта, гілка на пристрій:
|
||||||
|
`refs/heads/device/<device_id>`, файл `<пристрій>/<тип>.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 — зелені
|
||||||
|
```
|
||||||
|
|
|
||||||
27
ROADMAP.md
27
ROADMAP.md
|
|
@ -24,7 +24,7 @@ API віддає готове полотно з живими статусами,
|
||||||
| Автовиявлення LLDP/CDP/ARP/FDB | ✅ | ✅ |
|
| Автовиявлення LLDP/CDP/ARP/FDB | ✅ | ✅ |
|
||||||
| **Користувачі, ролі, вхід** | ✅ | ✅ |
|
| **Користувачі, ролі, вхід** | ✅ | ✅ |
|
||||||
| **Шаблони опитування** | ❌ | ❌ |
|
| **Шаблони опитування** | ❌ | ❌ |
|
||||||
| **NCM (збір конфігів)** | ✅ | ⚠️ збір працює, Git і розклад — ні |
|
| **NCM (збір конфігів)** | ✅ | ⚠️ збір, розклад і Git є; лишився тригер за Syslog |
|
||||||
| **Керування зондом із UI** | ✅ | ⚠️ транспорт є |
|
| **Керування зондом із UI** | ✅ | ⚠️ транспорт є |
|
||||||
| **Алерти й сповіщення** | ✅ | ✅ |
|
| **Алерти й сповіщення** | ✅ | ✅ |
|
||||||
| **Мобільна адаптивність, PWA** | — | ⚠️ адаптив є, PWA немає |
|
| **Мобільна адаптивність, PWA** | — | ⚠️ адаптив є, PWA немає |
|
||||||
|
|
@ -173,14 +173,19 @@ JSONPath, `discard unchanged`) — на сервері при записі: ін
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Етап 7. NCM — збір конфігів до кінця — ⚠️ наполовину, 2026-08-16
|
## Етап 7. NCM — збір конфігів до кінця — ⚠️ майже, 2026-08-25
|
||||||
|
|
||||||
> **Зроблено:** агентський модуль SSH/Telnet, черга завдань, диспетчер,
|
> **Зроблено:** агентський модуль SSH/Telnet, черга завдань, диспетчер,
|
||||||
> вивантаження зі стисненням і дедуплікацією, візуальний diff у вебі,
|
> вивантаження зі стисненням і дедуплікацією, візуальний diff у вебі,
|
||||||
> планувальник за cron. Перевірено наскрізно.
|
> планувальник за cron, Git-двигун на go-git, порівняння з довільною
|
||||||
|
> версією в UI, переливання історії командою `netpulse-gitsync`.
|
||||||
|
> Перевірено наскрізно.
|
||||||
>
|
>
|
||||||
> **Лишилось:** тригер за Syslog, Git-двигун, порівняння з довільною
|
> **Лишилось:** тригер за Syslog. Він упирається не в тригер: приймача
|
||||||
> версією в UI.
|
> syslog немає взагалі — `ts.syslog` і `WriteLogs` у сховищі є, але на
|
||||||
|
> агенті немає модуля, який слухав би 514/udp, і транспорт до сервера
|
||||||
|
> не під'єднано. Це окрема робота розміром із власний етап, а не
|
||||||
|
> дописування умови до наявного коду.
|
||||||
|
|
||||||
## Етап 7 (початковий план)
|
## Етап 7 (початковий план)
|
||||||
|
|
||||||
|
|
@ -198,13 +203,13 @@ JSONPath, `discard unchanged`) — на сервері при записі: ін
|
||||||
тригер за Syslog-подією (`on_syslog`, `%SYS-5-CONFIG_I`) — поле в схемі є,
|
тригер за Syslog-подією (`on_syslog`, `%SYS-5-CONFIG_I`) — поле в схемі є,
|
||||||
читати його нікому.
|
читати його нікому.
|
||||||
|
|
||||||
**Git-двигун.** `go-git` замість libgit2 — **чистий Go, без cgo**, що зберігає
|
**Git-двигун.** ✅ `go-git`, чистий Go без cgo. Голий репозиторій на тенанта,
|
||||||
статичний бінарник без залежностей. Комітить у `refs/heads/device/<id>`, віддає
|
гілка `refs/heads/device/<device_id>` на пристрій, файл `<пристрій>/<тип>.cfg`.
|
||||||
diff між ревізіями. Зараз `commit_sha` тимчасово містить hex контентного хеша, і
|
Однаковий вміст нового коміту не створює. `netpulse-gitsync` переливає вже
|
||||||
міняти доведеться лише джерело цього поля.
|
накопичену історію й відтворює втрачений репозиторій із бази.
|
||||||
|
|
||||||
**UI:** список версій, side-by-side diff, кнопка відкату (сутність
|
**UI:** ✅ список версій і порівняння з довільною попередньою. Кнопка відкату
|
||||||
`ncm.rollbacks` із двоетапним погодженням уже є).
|
лишилась (сутність `ncm.rollbacks` із двоетапним погодженням уже є).
|
||||||
|
|
||||||
**Обсяг:** ~4–5 днів.
|
**Обсяг:** ~4–5 днів.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@
|
||||||
| `collector` | gRPC-колектор, до якого підключаються зонди | 9443 |
|
| `collector` | gRPC-колектор, до якого підключаються зонди | 9443 |
|
||||||
| `proxy` | Caddy: сертифікати, HTTPS, проксі до двох служб | 80/443/9443 |
|
| `proxy` | Caddy: сертифікати, HTTPS, проксі до двох служб | 80/443/9443 |
|
||||||
|
|
||||||
|
Історія конфігів живе в Git на томі `git-data`, спільному для `api` й
|
||||||
|
`collector`: перший читає її для порівняння версій, другий пише під час
|
||||||
|
бекапу.
|
||||||
|
|
||||||
Назовні дивиться лише `proxy`. `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
|
```cron
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,12 @@ services:
|
||||||
environment:
|
environment:
|
||||||
<<: *server-env
|
<<: *server-env
|
||||||
NETPULSE_JWT_SECRET: ${NETPULSE_JWT_SECRET:?потрібен NETPULSE_JWT_SECRET — не менше 32 байтів}
|
NETPULSE_JWT_SECRET: ${NETPULSE_JWT_SECRET:?потрібен NETPULSE_JWT_SECRET — не менше 32 байтів}
|
||||||
|
# Історія конфігів у Git. Спільний том для API й колектора:
|
||||||
|
# колектор туди пише під час бекапу, API звідти читає для порівняння
|
||||||
|
# версій. Різні томи означали б, що половина історії видна одному
|
||||||
|
# процесу, а половина другому.
|
||||||
|
volumes:
|
||||||
|
- git-data:/var/lib/netpulse
|
||||||
depends_on:
|
depends_on:
|
||||||
migrate:
|
migrate:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
|
|
@ -106,6 +112,12 @@ services:
|
||||||
- -listen=:9443
|
- -listen=:9443
|
||||||
- -insecure
|
- -insecure
|
||||||
environment: *server-env
|
environment: *server-env
|
||||||
|
# Історія конфігів у Git. Спільний том для API й колектора:
|
||||||
|
# колектор туди пише під час бекапу, API звідти читає для порівняння
|
||||||
|
# версій. Різні томи означали б, що половина історії видна одному
|
||||||
|
# процесу, а половина другому.
|
||||||
|
volumes:
|
||||||
|
- git-data:/var/lib/netpulse
|
||||||
depends_on:
|
depends_on:
|
||||||
migrate:
|
migrate:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
|
|
@ -165,6 +177,7 @@ services:
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db-data:
|
db-data:
|
||||||
|
git-data:
|
||||||
cache-data:
|
cache-data:
|
||||||
caddy-data:
|
caddy-data:
|
||||||
caddy-config:
|
caddy-config:
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import (
|
||||||
"github.com/netpulse/netpulse/server/internal/alerting"
|
"github.com/netpulse/netpulse/server/internal/alerting"
|
||||||
"github.com/netpulse/netpulse/server/internal/auth"
|
"github.com/netpulse/netpulse/server/internal/auth"
|
||||||
"github.com/netpulse/netpulse/server/internal/crypto"
|
"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/internal/httpapi"
|
||||||
"github.com/netpulse/netpulse/server/webui"
|
"github.com/netpulse/netpulse/server/webui"
|
||||||
"github.com/netpulse/netpulse/server/internal/store"
|
"github.com/netpulse/netpulse/server/internal/store"
|
||||||
|
|
@ -51,6 +52,8 @@ func run() error {
|
||||||
"як часто обчислювати правила алертів; 0 — не запускати движок")
|
"як часто обчислювати правила алертів; 0 — не запускати движок")
|
||||||
alertKeep = flag.Duration("alert-retention", 7*24*time.Hour,
|
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",
|
privateHooks = flag.Bool("allow-private-webhooks",
|
||||||
os.Getenv("NETPULSE_ALLOW_PRIVATE_WEBHOOKS") == "1",
|
os.Getenv("NETPULSE_ALLOW_PRIVATE_WEBHOOKS") == "1",
|
||||||
"дозволити вебхуки на внутрішні адреси — для self-hosted інсталяцій")
|
"дозволити вебхуки на внутрішні адреси — для self-hosted інсталяцій")
|
||||||
|
|
@ -87,6 +90,14 @@ func run() error {
|
||||||
return fmt.Errorf("-dek: %w", err)
|
return fmt.Errorf("-dek: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сховище версій конфігів. Спільний каталог для API й колектора:
|
||||||
|
// колектор туди пише під час бекапу, API звідти читає для diff.
|
||||||
|
// Різні машини потребують спільного тому — інакше половина історії
|
||||||
|
// буде видна одному процесу, а половина другому.
|
||||||
|
if *gitRoot != "" {
|
||||||
|
st.UseGit(gitstore.New(*gitRoot))
|
||||||
|
}
|
||||||
|
|
||||||
api := httpapi.New(st, signer, log)
|
api := httpapi.New(st, signer, log)
|
||||||
|
|
||||||
// Зібраний інтерфейс, якщо він є в цій збірці. Порожній dist —
|
// Зібраний інтерфейс, якщо він є в цій збірці. Порожній dist —
|
||||||
|
|
|
||||||
133
server/cmd/netpulse-gitsync/main.go
Normal file
133
server/cmd/netpulse-gitsync/main.go
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
// Команда netpulse-gitsync — переливання історії конфігів у Git.
|
||||||
|
//
|
||||||
|
// Потрібна двічі за життя інсталяції.
|
||||||
|
//
|
||||||
|
// Коли версіювання вмикають на системі, яка вже місяцями збирає конфіги:
|
||||||
|
// без переливання репозиторій починається з наступної зміни, і вся
|
||||||
|
// накопичена історія лишається невидимою саме тоді, коли її вперше
|
||||||
|
// захотіли подивитись.
|
||||||
|
//
|
||||||
|
// І коли диск із репозиторієм втрачено. Тіла конфігів лежать
|
||||||
|
// зашифрованими в базі, тож Git повністю відтворюваний з неї —
|
||||||
|
// зворотне невірно, і саме тому база лишається джерелом істини.
|
||||||
|
//
|
||||||
|
// netpulse-gitsync -dsn ... -dek np1=<hex> -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=<hex|base64>[,...]")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/netpulse/netpulse/server/internal/crypto"
|
"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/grpcapi"
|
||||||
"github.com/netpulse/netpulse/server/internal/store"
|
"github.com/netpulse/netpulse/server/internal/store"
|
||||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
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 — лише локальний стенд")
|
insecure = flag.Bool("insecure", os.Getenv("NETPULSE_INSECURE") == "1", "без TLS — лише локальний стенд")
|
||||||
keysFlag = flag.String("dek", os.Getenv("NETPULSE_DEK"), "ключі шифрування: key_id=<hex|base64>[,...]")
|
keysFlag = flag.String("dek", os.Getenv("NETPULSE_DEK"), "ключі шифрування: key_id=<hex|base64>[,...]")
|
||||||
logLevel = flag.String("log-level", envOr("NETPULSE_LOG_LEVEL", "info"), "debug|info|warn|error")
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
|
|
@ -68,6 +71,14 @@ func run() error {
|
||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
|
// Сховище версій конфігів. Спільний каталог для API й колектора:
|
||||||
|
// колектор туди пише під час бекапу, API звідти читає для diff.
|
||||||
|
// Різні машини потребують спільного тому — інакше половина історії
|
||||||
|
// буде видна одному процесу, а половина другому.
|
||||||
|
if *gitRoot != "" {
|
||||||
|
st.UseGit(gitstore.New(*gitRoot))
|
||||||
|
}
|
||||||
|
|
||||||
svc := grpcapi.New(st, ring, log)
|
svc := grpcapi.New(st, ring, log)
|
||||||
|
|
||||||
opts := []grpc.ServerOption{
|
opts := []grpc.ServerOption{
|
||||||
|
|
|
||||||
|
|
@ -6,21 +6,39 @@ require (
|
||||||
github.com/coder/websocket v1.8.15
|
github.com/coder/websocket v1.8.15
|
||||||
github.com/jackc/pgx/v5 v5.7.6
|
github.com/jackc/pgx/v5 v5.7.6
|
||||||
github.com/netpulse/netpulse/gen/go v0.0.0
|
github.com/netpulse/netpulse/gen/go v0.0.0
|
||||||
golang.org/x/crypto v0.51.0
|
golang.org/x/crypto v0.53.0
|
||||||
golang.org/x/term v0.43.0
|
golang.org/x/term v0.44.0
|
||||||
google.golang.org/grpc v1.83.0
|
google.golang.org/grpc v1.83.0
|
||||||
google.golang.org/protobuf v1.36.12
|
google.golang.org/protobuf v1.36.12
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
golang.org/x/net v0.55.0 // indirect
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
golang.org/x/text v0.37.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
|
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
|
replace github.com/netpulse/netpulse/gen/go => ../gen/go
|
||||||
|
|
|
||||||
|
|
@ -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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
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 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
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 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
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/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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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/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.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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
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.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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
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/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 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
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 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
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 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
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 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
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 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
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 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
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 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
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=
|
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 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
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 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
|
||||||
460
server/internal/gitstore/store.go
Normal file
460
server/internal/gitstore/store.go
Normal file
|
|
@ -0,0 +1,460 @@
|
||||||
|
// Package gitstore — версіювання конфігів пристроїв у Git.
|
||||||
|
//
|
||||||
|
// Один голий репозиторій на тенанта, гілка на пристрій:
|
||||||
|
// refs/heads/device/<device_id>. Гілка за ідентифікатором, а не за
|
||||||
|
// іменем: пристрій перейменовують, і історія не має від цього
|
||||||
|
// розсипатись на дві.
|
||||||
|
//
|
||||||
|
// Голий репозиторій, без робочого каталогу: конфіги й так лежать в
|
||||||
|
// об'єктах 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 — шлях репозиторію відносно кореня, наприклад "<tenant>.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/<device_id>`" + `.
|
||||||
|
Усередині гілки — файли на кожен зріз: ` + "`<пристрій>/<тип>.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
|
||||||
|
}
|
||||||
140
server/internal/gitstore/store_test.go
Normal file
140
server/internal/gitstore/store_test.go
Normal file
|
|
@ -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("порожній корінь мав дати помилку")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,9 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netpulse/netpulse/server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedUser заводить людину з паролем і роллю в тенанті фікстури.
|
// seedUser заводить людину з паролем і роллю в тенанті фікстури.
|
||||||
|
|
@ -190,13 +193,22 @@ func TestForgedTokenRejected(t *testing.T) {
|
||||||
f.seedUser(t, "user@example.com", "correct-horse-battery", "admin")
|
f.seedUser(t, "user@example.com", "correct-horse-battery", "admin")
|
||||||
_, out, _ := f.login(t, "user@example.com", "correct-horse-battery")
|
_, 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' {
|
// Останній символ base64url несе лише 4 значущі біти з шести, а два
|
||||||
bad += "B"
|
// зайві декодер відкидає. Тому 'A' і 'B' на останньому місці дають
|
||||||
} else {
|
// ті самі 32 байти підпису, токен лишається дійсним, і тест падає
|
||||||
bad += "A"
|
// приблизно в кожному третьому запуску — залежно від того, який
|
||||||
|
// символ випав цього разу.
|
||||||
|
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 {
|
if code, _ := f.get(t, "/api/v1/maps", bad); code != http.StatusUnauthorized {
|
||||||
t.Fatalf("підроблений токен прийнято: код %d", code)
|
t.Fatalf("підроблений токен прийнято: код %d", code)
|
||||||
|
|
@ -235,10 +247,31 @@ func TestRefreshRotatesSession(t *testing.T) {
|
||||||
t.Fatal("refresh-токен не змінився — ротації немає")
|
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 {
|
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) {
|
func TestLoginWithoutMembership(t *testing.T) {
|
||||||
f := setup(t)
|
f := setup(t)
|
||||||
|
|
||||||
hash := "" // створюємо користувача напряму, без членства
|
// Логін обов'язковий: вхід за іменем користувача з'явився пізніше
|
||||||
_ = hash
|
// за цей тест, і колонка стала NOT NULL.
|
||||||
if _, err := f.pool.Exec(f.ctx, `
|
if _, err := f.pool.Exec(f.ctx, `
|
||||||
INSERT INTO core.users (email, password_hash, email_verified_at)
|
INSERT INTO core.users (username, email, password_hash, email_verified_at)
|
||||||
VALUES ('orphan@example.com', $1, now())
|
VALUES ('orphan', 'orphan@example.com', $1, now())
|
||||||
`, mustHash(t, "correct-horse-battery")); err != nil {
|
`, mustHash(t, "correct-horse-battery")); err != nil {
|
||||||
t.Fatalf("seed: %v", err)
|
t.Fatalf("seed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,15 @@ type MapPermission struct {
|
||||||
// maps:read»: у типовій інсталяції мапа одна на всіх, і вимагати
|
// maps:read»: у типовій інсталяції мапа одна на всіх, і вимагати
|
||||||
// налаштувати доступ до неї означало б зробити типовий випадок
|
// налаштувати доступ до неї означало б зробити типовий випадок
|
||||||
// найдовшим.
|
// найдовшим.
|
||||||
|
//
|
||||||
|
// Порожній userID — це машинний токен, а не анонім. Токен не належить
|
||||||
|
// до жодної групи, тож груповий ACL до нього не застосовний: його межі
|
||||||
|
// задають scopes, які перевіряє обробник. Повертати йому «deny» означало
|
||||||
|
// б зламати інтеграції першою ж налаштованою групою на мапі.
|
||||||
func (s *Store) MapAccess(ctx context.Context, tenantID, userID, mapID string) (string, error) {
|
func (s *Store) MapAccess(ctx context.Context, tenantID, userID, mapID string) (string, error) {
|
||||||
|
if userID == "" {
|
||||||
|
return "write", nil
|
||||||
|
}
|
||||||
var level string
|
var level string
|
||||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||||
return tx.QueryRow(ctx,
|
return tx.QueryRow(ctx,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
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_nodes n WHERE n.map_id = m.id),
|
||||||
(SELECT count(*) FROM topo.map_edges e WHERE e.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
|
FROM topo.maps m
|
||||||
WHERE m.tenant_id = $1 AND m.deleted_at IS NULL
|
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
|
ORDER BY m.is_default DESC, m.name
|
||||||
`, tenantID, userID)
|
`, tenantID, nullUUID(userID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,12 @@ import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/netpulse/netpulse/server/internal/crypto"
|
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||||
|
"github.com/netpulse/netpulse/server/internal/gitstore"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConfigSubmission — зібраний із чанків конфіг.
|
// ConfigSubmission — зібраний із чанків конфіг.
|
||||||
|
|
@ -34,14 +36,17 @@ type ConfigOutcome struct {
|
||||||
var ErrChecksumMismatch = errors.New("контрольна сума не збіглася")
|
var ErrChecksumMismatch = errors.New("контрольна сума не збіглася")
|
||||||
|
|
||||||
// StoreConfig приймає конфіг: звіряє суму, визначає, чи є зміна,
|
// StoreConfig приймає конфіг: звіряє суму, визначає, чи є зміна,
|
||||||
// і зберігає нову версію.
|
// зберігає нову версію й кладе її в Git.
|
||||||
//
|
//
|
||||||
// СТАН РЕАЛІЗАЦІЇ: Git-двигун (libgit2) ще не підключено. Тіло
|
// Два сховища замість одного — свідомо. Тіло лежить зашифрованим у
|
||||||
// зберігається зашифрованим у core.secrets, а commit_sha тимчасово
|
// core.secrets і відповідає на «який конфіг зараз»; Git відповідає на
|
||||||
// містить hex контентного хеша. Усе інше — дедуплікація, підрахунок
|
// «що і коли змінилось за півроку». Друге питання ставлять під час
|
||||||
// рядків, ланцюжок prev_config_id — працює вже зараз, тому diff між
|
// розбору аварії, і відповідь не має залежати від того, чи не почистив
|
||||||
// версіями будується без Git. Коли двигун з'явиться, зміниться лише
|
// таблицю ретеншен.
|
||||||
// джерело commit_sha.
|
//
|
||||||
|
// Без під'єднаного Git (UseGit не викликали) працює як і раніше:
|
||||||
|
// commit_sha містить hex контентного хеша. Це робочий режим для
|
||||||
|
// інсталяцій без окремого диска під історію.
|
||||||
func (s *Store) StoreConfig(ctx context.Context, a *Agent, sub ConfigSubmission, ring *crypto.Keyring) (ConfigOutcome, error) {
|
func (s *Store) StoreConfig(ctx context.Context, a *Agent, sub ConfigSubmission, ring *crypto.Keyring) (ConfigOutcome, error) {
|
||||||
sum := sha256.Sum256(sub.Body)
|
sum := sha256.Sum256(sub.Body)
|
||||||
if len(sub.ClaimedHash) > 0 && !equalBytes(sum[:], sub.ClaimedHash) {
|
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)
|
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
|
var prevArg any
|
||||||
if hasPrev {
|
if hasPrev {
|
||||||
|
|
@ -146,8 +178,8 @@ func (s *Store) StoreConfig(ctx context.Context, a *Agent, sub ConfigSubmission,
|
||||||
prev_config_id, is_change)
|
prev_config_id, is_change)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13::uuid,$14)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13::uuid,$14)
|
||||||
RETURNING id::text
|
RETURNING id::text
|
||||||
`, a.TenantID, sub.DeviceID, repoID, hexSum, hexSum,
|
`, a.TenantID, sub.DeviceID, repoID, commitSHA, blobSHA,
|
||||||
"device/"+deviceName, path, configType,
|
branch, path, configType,
|
||||||
len(sub.Body), lines, sum[:], secretID, prevArg, hasPrev).Scan(&out.ConfigID)
|
len(sub.Body), lines, sum[:], secretID, prevArg, hasPrev).Scan(&out.ConfigID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("вставка версії конфігу: %w", err)
|
return fmt.Errorf("вставка версії конфігу: %w", err)
|
||||||
|
|
@ -162,7 +194,7 @@ func (s *Store) StoreConfig(ctx context.Context, a *Agent, sub ConfigSubmission,
|
||||||
}
|
}
|
||||||
|
|
||||||
out.Accepted = true
|
out.Accepted = true
|
||||||
out.CommitSHA = hexSum
|
out.CommitSHA = commitSHA
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -180,3 +212,27 @@ func equalBytes(a, b []byte) bool {
|
||||||
}
|
}
|
||||||
return true
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
164
server/internal/store/ncm_git.go
Normal file
164
server/internal/store/ncm_git.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -19,12 +19,24 @@ import (
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/netpulse/netpulse/server/internal/gitstore"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
pool *pgxpool.Pool
|
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) {
|
func New(ctx context.Context, dsn string) (*Store, error) {
|
||||||
cfg, err := pgxpool.ParseConfig(dsn)
|
cfg, err := pgxpool.ParseConfig(dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -313,28 +313,48 @@ function DeviceConfigs({
|
||||||
</ul>
|
</ul>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{open && <DiffView version={open} />}
|
{open && <DiffView version={open} versions={versions} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiffView({ version }: { version: ConfigVersion }) {
|
function DiffView({
|
||||||
|
version,
|
||||||
|
versions,
|
||||||
|
}: {
|
||||||
|
version: ConfigVersion
|
||||||
|
versions: ConfigVersion[]
|
||||||
|
}) {
|
||||||
const [diff, setDiff] = useState<DiffResult | null>(null)
|
const [diff, setDiff] = useState<DiffResult | null>(null)
|
||||||
const [body, setBody] = useState<string | null>(null)
|
const [body, setBody] = useState<string | null>(null)
|
||||||
const [mode, setMode] = useState<'diff' | 'full'>('diff')
|
const [mode, setMode] = useState<'diff' | 'full'>('diff')
|
||||||
const [err, setErr] = useState<string | null>(null)
|
const [err, setErr] = useState<string | null>(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(() => {
|
useEffect(() => {
|
||||||
setDiff(null)
|
setDiff(null)
|
||||||
setBody(null)
|
setBody(null)
|
||||||
setErr(null)
|
setErr(null)
|
||||||
api
|
api
|
||||||
.diffConfig(version.id)
|
.diffConfig(version.id, base || undefined)
|
||||||
.then(setDiff)
|
.then(setDiff)
|
||||||
.catch((e) => setErr(e instanceof Error ? e.message : String(e)))
|
.catch((e) => setErr(e instanceof Error ? e.message : String(e)))
|
||||||
}, [version.id])
|
}, [version.id, base])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== 'full' || body !== null) return
|
if (mode !== 'full' || body !== null) return
|
||||||
|
|
@ -354,6 +374,24 @@ function DiffView({ version }: { version: ConfigVersion }) {
|
||||||
<span className="text-red-400">−{diff.lines_removed}</span>
|
<span className="text-red-400">−{diff.lines_removed}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{mode === 'diff' && earlier.length > 0 && (
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||||
|
з
|
||||||
|
<select
|
||||||
|
className="rounded border border-slate-700 bg-slate-800 px-1.5 py-1
|
||||||
|
text-xs text-slate-200"
|
||||||
|
value={base}
|
||||||
|
onChange={(e) => setBase(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">попередньою</option>
|
||||||
|
{earlier.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{fmtDate(v.collected_at)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<div className="ml-auto flex gap-1">
|
<div className="ml-auto flex gap-1">
|
||||||
<Button kind={mode === 'diff' ? 'primary' : 'default'} onClick={() => setMode('diff')}>
|
<Button kind={mode === 'diff' ? 'primary' : 'default'} onClick={() => setMode('diff')}>
|
||||||
Зміни
|
Зміни
|
||||||
|
|
@ -384,6 +422,10 @@ function DiffView({ version }: { version: ConfigVersion }) {
|
||||||
<p className="px-3 py-8 text-center text-xs text-slate-600">
|
<p className="px-3 py-8 text-center text-xs text-slate-600">
|
||||||
Перша зібрана версія — порівнювати нема з чим
|
Перша зібрана версія — порівнювати нема з чим
|
||||||
</p>
|
</p>
|
||||||
|
) : diff.hunks.length === 0 && base ? (
|
||||||
|
<p className="px-3 py-8 text-center text-xs text-slate-600">
|
||||||
|
Ці дві версії однакові
|
||||||
|
</p>
|
||||||
) : diff.hunks.length === 0 ? (
|
) : diff.hunks.length === 0 ? (
|
||||||
<p className="px-3 py-8 text-center text-xs text-slate-600">Змін немає</p>
|
<p className="px-3 py-8 text-center text-xs text-slate-600">Змін немає</p>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue