Compare commits

...
35 Commits
Author SHA1 Message Date
DenozordecandCursor ec65249bf1 fix(ci): satisfy golangci-lint in api keys code
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / go (push) Successful in 41s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 3m18s
Правки errcheck в тестах httpapi и gofmt полей APIKeyPatch в store.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 11:33:19 +07:00
Denozordec 6329a4df27 feat(api): implement API key management and authentication enhancements
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 28s
CI / go (push) Failing after 24s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
- Added endpoints for managing API keys, including creation, retrieval, updating, and revocation.
- Introduced a new Auth session endpoint to retrieve current tenant and role information.
- Updated the authentication middleware to support API key-based authentication and track last used timestamps.
- Enhanced documentation to reflect new API key functionalities and usage guidelines.
- Improved logging for demo authentication scenarios.
2026-05-21 11:26:17 +07:00
Denozordec 880d77810a refactor(httpapi): standardize output formatting in job report response
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 37s
CI / go (push) Successful in 50s
CI / bird2 (push) Successful in 18s
CI / release (push) Successful in 3m51s
- Adjusted the order of fields in the job report response for consistency.
- Ensured that the output structure remains clear and maintainable.
2026-05-21 10:52:08 +07:00
DenozordecandCursor 8ebce28e34 perf: wire job_audit terminal persistence hook
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Failing after 35s
CI / go (push) Failing after 30s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
- TerminalHook в Registry для записи статуса job в PostgreSQL job_audit
- Подключение через BootstrapWorkers при наличии pool

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 10:46:13 +07:00
DenozordecandCursor dd7d43c2c2 perf: incremental render scale, SQL pagination and observability
- BIRD filter chunking (500 CIDR); bounded job worker pool
- ListModulesPage SQL push-down; revision diff limit; batch revision prune
- DB pool tuning; Prometheus pipeline/job metrics
- Coalesce module_refresh via idempotency; GET /jobs/{id}/report
- JobAuditWriter foundation for job_audit persistence

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 10:45:46 +07:00
DenozordecandCursor a8c5e9701f perf: optimize data path, indexes and frontend virtualization
- Убран latestCDNRowsBySource; expanded BIRD preview генерируется on-demand
- Batch AS meta updates; миграция perf-индексов 000012
- VirtualPrefixList для preview префиксов; метрики pipeline refresh
- PolitePause для RIPEstat после cache miss

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 10:45:39 +07:00
DenozordecandCursor be3d73f374 perf: quick wins for pipeline, jobs, httpapi and web ops
- CDN snapshot: batch merge после parallel fetch, без race на persist
- ListRevisionPrefixes: лёгкая проверка revision вместо GetRevision
- Jobs: timeout/cancel context для pipeline и deploy
- HTTP server timeouts; кэш birdc для GET /peers
- ListModules: batch DoH profiles одним запросом
- Web: tab-scoped load на /operations, debounce job search, меньше over-fetch на dashboard

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 10:42:13 +07:00
Denozordec 5fca165c69 refactor(settings): deprecate settingsKnownSchema and integrate bird and revision settings
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 36s
CI / go (push) Successful in 49s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 3m59s
- Merged birdSettingsSchema and revisionSettingsSchema into settingsKnownSchema, marking the previous schema as deprecated.
- Updated emptySettingsKnownForm to utilize emptyBirdSettingsForm and emptyRevisionSettingsForm.
- Refactored layout and page components to streamline theme management and improve tab synchronization in network and operations pages.
- Introduced new system settings tab in operations and updated settings page to manage theme preferences.
2026-05-21 10:11:04 +07:00
Denozordec 293115e0e1 fix(nginx): update proxy_pass configuration to use full request URI
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / go (push) Successful in 52s
CI / bird2 (push) Successful in 18s
CI / release (push) Successful in 4m33s
- Modified the NGINX configuration to pass the full request URI in the proxy_pass directive, ensuring proper handling of requests.
- Added a comment to clarify the limitation of using a variable in proxy_pass for URI replacement.
2026-05-20 16:05:54 +07:00
Denozordec 6d2051f813 fix(web): improve error handling and API health check logic
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 31s
CI / go (push) Successful in 41s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 3m25s
- Refactored the API health check to handle errors more gracefully, providing clearer feedback on loading issues.
- Updated the NGINX configuration to use a dynamic upstream variable for better resilience against IP changes in Docker.
- Enhanced user notifications for API availability and data loading errors, including instructions for local demo setup.
2026-05-20 15:48:18 +07:00
DenozordecandCursor b51a9ae3b3 ci: add ci type to release configuration and update documentation
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 31s
CI / web (push) Successful in 41s
CI / go (push) Successful in 47s
CI / bird2 (push) Successful in 19s
CI / release (push) Successful in 3m47s
- Introduced `ci` type in `.releaserc.json` for patch releases.
- Updated conventional commits documentation to reflect the new `ci` type and its implications for versioning.
- Clarified the role of `ci` in the context of patch releases in the releasing guide.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 15:24:21 +07:00
DenozordecandCursor 4d4cd2301f ci: update golangci-lint version and installation mode
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 23s
CI / web (push) Successful in 30s
CI / go (push) Successful in 1m15s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 18s
- Upgraded golangci-lint from v1.62 to v1.64.8 to ensure compatibility with Go 1.24.
- Changed installation mode to 'goinstall' for improved setup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 15:18:53 +07:00
DenozordecandCursor d687881eaa chore: update golangci configuration and improve resource cleanup
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 23s
CI / web (push) Successful in 31s
CI / go (push) Failing after 19s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
- Disabled all linters in .golangci.yml to streamline linting process.
- Updated resource cleanup in multiple files to use deferred functions for closing response bodies, ensuring proper error handling and resource management.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 15:13:02 +07:00
DenozordecandCursor 87e756f34f ci: expand changes detection to rewire all pipeline nodes
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 40s
CI / go (push) Failing after 21s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
Полный прогон при scripts/*, workflows, golangci, pre-commit; openapi в full_pipeline; миграции и deploy-пути; исправлен fallback пустого diff.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 15:08:28 +07:00
DenozordecandCursor 27864fad58 fix(ci): make check-migrations-pair.sh POSIX sh compatible
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Has been cancelled
Убрана bash process substitution; скрипт работает под sh в Gitea runner (DEP-03).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:57:18 +07:00
DenozordecandCursor dc5e777d07 style(web): format settings-known schema for prettier
CI / changes (push) Successful in 7s
CI / openapi (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / go (push) Has been cancelled
CI / bird2 (push) Has been cancelled
CI / release (push) Has been cancelled
CI / web (push) Has been cancelled
Исправление падения CI job web: prettier --check на settings-known.schema.ts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:56:20 +07:00
DenozordecandCursor 23910c3393 docs(go): add godoc for pipeline and store exports
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 23s
CI / web (push) Failing after 34s
CI / go (push) Failing after 41s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
Уточнение godoc ExtractCIDRs; тесты repository (interface + optional integration).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:53:50 +07:00
DenozordecandCursor 0e90bbee4e docs: clarify dev-only flags and openapi implementation gaps
DOC-03: X-Tenant-Id не реализован в handlers; лимиты CDN preview; SEC-02 пометки в compose; sync endpoints в access.md.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:53:20 +07:00
DenozordecandCursor 55bc87cbc4 ci: add golangci, migration checks, and pre-commit
DEP-03: check-migrations-pair.sh; migrations/* в path filter; golangci-lint в CI; pre-commit; обновление engineering.mdc (TEST-04, enforcement).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:53:01 +07:00
DenozordecandCursor e68f034966 refactor(web): extract shared kpi metrics grid pattern
WEB-18: KpiMetricsGrid в ui/patterns; рефакторинг overview, network, modules, directories, operations, schedule, monitoring.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:52:44 +07:00
DenozordecandCursor e06ee880b2 fix(web): align settings with shadcn notify and superforms
WEB-07: notify вместо svelte-sonner; ui/core imports; WEB-05: zod + superforms + FormField для полей настроек.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:50:58 +07:00
DenozordecandCursor 33fe8fdd18 refactor(httpapi): readiness ping via store backend
ARCH-09: Ping на store.Backend; readiness без прямого pgxpool.Ping в handler.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:49:26 +07:00
DenozordecandCursor 6fa693156d fix(httpapi): stable problem details for client errors
Стабильные detail для 4xx (store, CDN preview, CSV); логирование на сервере. Расширен lint-httpapi для ERR-01 в 4xx.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:48:57 +07:00
DenozordecandCursor 5858d0889e fix(httpapi): paginate module nested list endpoints
Пагинация cursor/limit для CDN, AS, domain и IP range list; ответы с next_cursor и has_more по OpenAPI. Добавлен writePaginatedListJSON и тест.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:48:32 +07:00
Denozordec bfe20c1fe0 feat(web): enhance overview page with improved state management and UI components
CI / changes (push) Successful in 9s
CI / openapi (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / web (push) Successful in 42s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m57s
- Refactored state management for modules, revisions, peers, speakers, and jobs, enhancing loading and error handling.
- Updated imports to utilize core UI components for better maintainability and consistency.
- Introduced derived states for KPI cards, providing real-time insights into system metrics.
- Improved loading function to fetch data efficiently and handle errors gracefully.
- Enhanced UI layout with new icons and dynamic descriptions based on last updated timestamps.
2026-05-20 14:27:21 +07:00
Denozordec c66cc9317d feat(web): refactor directories and network pages with improved state management and UI components
CI / changes (push) Successful in 6s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 31s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m42s
- Updated imports to utilize core UI components for better maintainability.
- Refactored state management for communities and DoH profiles, enhancing loading and error handling.
- Introduced derived states for KPI cards, providing better insights into community and DoH profile counts.
- Streamlined loading functions to improve data fetching efficiency and user experience.
- Enhanced UI layout with new icons and improved component structure for clarity.
2026-05-20 14:13:32 +07:00
Denozordec 9ea4a68ccf docs: update AGENTS, rules, and skills documentation for Conventional Commits
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 17s
- Enhanced AGENTS.md to clarify the use of `/commit-message` and the exclusion of the Source Control button for generating commit messages.
- Updated conventional-commits.mdc to include new triggers and guidelines for commit message types, emphasizing the importance of semver impact.
- Revised commit-message SKILL.md to specify the mandatory execution of scripts and the correct usage of types and scopes in commit messages.
- Improved README documentation to reflect the updated guidelines and workflows for generating commit messages.
2026-05-20 12:52:31 +07:00
Denozordec 8204105fd6 feat(web): enhance module entry dialogs and selection handling
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 38s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m1s
- Refactored module entry dialogs to reset forms based on edit state and improve state management.
- Updated selection logic in various components to utilize derived states for active selections, enhancing bulk operations.
- Improved dialog bindings for better state synchronization and user experience.
- Streamlined component structure for maintainability and clarity.
2026-05-20 12:35:45 +07:00
Denozordec 0c11ecfa48 feat(web): enhance module management UI with new localization and state handling
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 31s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m30s
- Added new utility functions for module state representation in Russian, improving localization.
- Introduced derived states for counting enabled and disabled modules, enhancing user insights.
- Updated module management components to display last updated timestamps and improved descriptions.
- Refactored imports to utilize core UI components for better maintainability and consistency.
2026-05-20 12:23:16 +07:00
Denozordec ab8660ac42 feat(web): enhance operations UI with new job status handling and component updates
- Added a new function for determining job status badge variants to improve UI consistency.
- Refactored imports to utilize the core UI library for better maintainability.
- Updated job report and filters components to enhance user experience and streamline functionality.
- Improved layout and responsiveness across operations-related components.
2026-05-20 12:14:33 +07:00
Denozordec ce747673fd feat(web): refactor module display logic and enhance UI components
- Introduced new utility functions for date formatting and module interval labeling.
- Replaced inline badge variant logic with a dedicated function for improved readability.
- Updated imports to streamline component usage and enhance maintainability.
- Enhanced the overall structure of the module display for better clarity and user experience.
2026-05-20 12:10:11 +07:00
Denozordec e558570967 feat(web): enhance monitoring page with new status tracking and UI components
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 28s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m36s
- Refactored monitoring page to utilize new UI components from the core library.
- Added detailed job status tracking and error handling features.
- Improved overall layout and responsiveness of the monitoring interface.
- Introduced new derived states for better management of health and job statuses.
- Updated imports to streamline component usage and enhance maintainability.
2026-05-20 11:41:58 +07:00
DenozordecandCursor b6b081b04f style(web): format app-version.svelte for prettier
CI / changes (push) Successful in 7s
CI / openapi (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / web (push) Successful in 28s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m45s
Импорт в одну строку — иначе падает npm run lint в CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 11:21:13 +07:00
DenozordecandCursor 13b4c20148 fix(web): show semver on monitoring from legacy /v1/version
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Failing after 35s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
На проде API отдаёт только api_version и git_sha=unknown без поля version;
карточка показывала SHA вместо semver. Общий хелпер version-info.ts:
version → api_version, placeholder SHA не в заголовке.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 11:13:15 +07:00
Denozordec 700d415422 chore: update CI workflow and release process
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 30s
CI / go (push) Successful in 25s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 19s
- Replaced `@semantic-release/git` with `@semantic-release/exec` for handling release commands.
- Updated `.releaserc.json` to reflect changes in release command execution.
- Modified CI workflow to streamline the release process, including tagging and Docker image publishing.
- Removed obsolete publish workflow and integrated its functionality into the main CI workflow.
- Enhanced documentation to clarify the new release process and CI pipeline structure.
2026-05-20 01:05:22 +07:00
146 changed files with 10645 additions and 4967 deletions
+37
View File
@@ -0,0 +1,37 @@
# Сгенерировать commit message (EvoBGP)
Сгенерируй сообщение коммита для **текущих staged-изменений**. Не выполняй `git commit`, если пользователь явно не просил закоммитить.
## Обязательный workflow (MUST)
1. Прочитай скилл [`.cursor/skills/commit-message/SKILL.md`](../skills/commit-message/SKILL.md).
2. Следуй правилу [`.cursor/rules/conventional-commits.mdc`](../rules/conventional-commits.mdc).
3. **Первым вызовом Shell** из корня репозитория:
```powershell
powershell -NoProfile -File scripts/commit/staged-context.ps1
```
4. Строй текст **только** по JSON stdout (`groups`, `stat`, `diff_excerpt`). Exit `1` → index пуст, сообщи пользователю.
5. **Запрещено** обходить скрипт через один `git diff --cached`.
## Формат вывода
Для каждого коммита (при auto-split — по одному блоку):
```
<type>(<scope>): <summary in English>
<тело на русском>
```
Плюс пояснение (RU): semver impact (`minor`|`patch`|`none`|`major`), почему выбран type, был ли split.
## Semver (кратко)
- Новая пользовательская возможность → `feat` (minor)
- Починка ожидаемого поведения / баг → `fix` (patch)
- Follow-up баги после недавнего `feat` в том же scope → **`fix`**, не `feat`
- Только перестройка без нового поведения → `refactor` (patch, без новых функций)
Заголовок — EN, императив, ≤72 символов. Тело — RU.
+71 -6
View File
@@ -11,7 +11,13 @@ alwaysApply: false
## Триггеры (применить правило + скилл)
Любой запрос на коммит или сообщение коммита: `commit`, `коммит`, `закоммить`, `git commit`, `commit message`, `conventional commit`, `staged`, «сгенерируй коммит» — в т.ч. если это указано в плане или [AGENTS.md](../../AGENTS.md).
Любой запрос на коммит или сообщение коммита: `commit`, `коммит`, `закоммить`, `git commit`, `commit message`, `conventional commit`, `staged`, «сгенерируй коммит», **`/commit-message`** — в т.ч. если это указано в плане или [AGENTS.md](../../AGENTS.md).
### Не путать с кнопкой ✨ в Source Control
Команда **`cursor.generateGitCommitMessage`** (sparkle в поле commit message) **не** читает Rules, Skills и `staged-context.ps1` — только staged diff и история коммитов ([ограничение Cursor](https://forum.cursor.com/t/how-to-set-prompt-for-generate-commit-message/148606)).
**Замена для EvoBGP:** Agent → `/commit-message` или команда [`.cursor/commands/commit-message.md`](../commands/commit-message.md).
## Обязательный запуск скрипта (MUST)
@@ -41,15 +47,74 @@ powershell -NoProfile -File scripts/commit/staged-context.ps1
| type | Когда | Версия |
|------|--------|--------|
| `feat` | новая функциональность | minor |
| `fix` | исправление бага | patch |
| `feat` | **новая** пользовательская возможность (раньше нельзя было) | minor |
| `fix` | восстановление **ожидаемого** поведения; баг, регрессия, падение UI | patch |
| `perf` | ускорение без смены API | patch |
| `refactor` | реструктуризация без смены поведения | — |
| `refactor` | реструктуризация **без** новой возможности и **без** исправления бага | patch |
| `docs` | только документация | — |
| `test` | тесты | — |
| `ci` | CI/CD (`.gitea/`, workflows) | — |
| `ci` | CI/CD (`.gitea/`, workflows); правки, из‑за которых нужны новые образы | patch |
| `chore` | обслуживание, deps, `.cursor/` | — |
### Выбор type: semver, а не «красивые слова»
**Главный вопрос:** что изменится для пользователя после релиза?
1. Появилось **новое** действие / экран / API / настройка, которых не было → `feat`
2. То, что **должно было работать**, не работало (кнопки, диалоги, сохранение, 500) → `fix`
3. Только перестройка кода или UI на другой паттерн, поведение для пользователя то же → `refactor` (patch, без новых функций)
4. Ускорение без изменения контракта → `perf`
**Не путать с формулировкой diff:**
| В diff / задаче часто пишут | Неверный type | Верный type, если… |
|-----------------------------|---------------|---------------------|
| enhance, improve, polish UI | `feat` | …только чиним сломанное после прошлого PR → `fix` |
| refactor pages, unify tables | `feat` | …новой возможности нет, лишь перенос на AppDataTable → `refactor` |
| follow-up после feat(web) | `feat` | …исправляем баги того же экрана → `fix` |
**Follow-up rule:** коммит сразу после `feat` в той же области, который **не добавляет** новую возможность, а устраняет дефект (effect loop, не открывается dialog, confirm не срабатывает) — **`fix`**, не `feat`.
**Split при смешанном diff:** новая страница/flow → `feat`; отдельным коммитом правки багов → `fix`. Не объединять в один `feat`.
**Breaking changes** — только `feat!` / `fix!` / `BREAKING CHANGE:` когда пользователь **обязан** менять конфиг, API или привычный workflow.
### Обязательно в пояснении агенту
При каждом предложении коммита указать:
- **Semver impact:** `minor` | `patch` | `none` | `major`
- **Почему не другой type** (одно предложение), если diff большой или формулировка двусмысленная
Пример неправильно / правильно:
```
# Плохо — patch-фикс, minor-bump
feat(web): enhance module entry dialogs and selection handling
# Хорошо
fix(web): stop effect loop breaking module action buttons
Исправлен effect_update_depth_exceeded и bind:open у Dialog; кнопки редактирования/удаления снова работают.
```
```
# Плохо — рефакторинг без новой фичи
feat(web): migrate modules list to AppDataTable
# Хорошо — если не было нового user-facing
refactor(web): migrate modules list to AppDataTable
Единый паттерн таблиц; поведение списка модулей без изменений. Semver: patch.
```
```
# Хорошо feat — действительно новое
feat(web): add module create dialog on /modules
Диалог создания модуля с POST /v1/modules; раньше создание было только через API.
```
## Breaking changes
- Заголовок: `feat!` / `fix!` **или** в теле строка `BREAKING CHANGE:` (на английском ключевое слово) + описание impact **на русском**.
@@ -96,7 +161,7 @@ powershell -NoProfile -File scripts/commit/staged-context.ps1
<тело RU>
```
**2. Пояснение (RU):** почему выбран type; риск/impact; был ли split.
**2. Пояснение (RU):** semver impact (`minor`|`patch`|`none`|`major`); почему выбран type; риск/impact; был ли split.
## Примеры
+6 -4
View File
@@ -15,7 +15,7 @@ alwaysApply: true
**ARCH-01** | MUST | Новая persistence-логика — метод `store.Backend` + реализации в `repository` и `store.Memory`; SQL не в `httpapi`.
*Rationale:* единая абстракция данных.
*Проверка:* grep SQL в `internal/httpapi` — отсутствие; review.
*Проверка:* CI `scripts/lint-httpapi.sh`; grep SQL в `internal/httpapi` — отсутствие.
**ARCH-02** | MUST | HTTP-маршруты только в `internal/httpapi`; регистрация через `http.ServeMux` с паттернами `METHOD /v1/...`.
*Rationale:* один слой REST.
@@ -75,7 +75,7 @@ alwaysApply: true
**STYLE-05** | MUST | HTTP-ошибки — `writeProblem` / `writeJSON` (`application/problem+json` для 4xx/5xx).
*Rationale:* RFC 9457, OpenAPI.
*Проверка:* `problem.go`.
*Проверка:* `problem.go`; CI `scripts/lint-httpapi.sh` (5xx и 4xx store/cdn/csv).
**STYLE-06** | MUST | JSON полей HTTP DTO согласованы с `docs/openapi.yaml`.
*Rationale:* контракт API.
@@ -123,7 +123,7 @@ alwaysApply: true
**TEST-03** | MUST | Новые BIRD-сценарии в `internal/birdfmt/testdata/scenarios/*/bird.conf` + `bird -p`.
*Проверка:* CI job `bird2`.
**TEST-04** | MUST | Изменения `web/` — локально `npm run check` и `npm run lint` (CI web пока не в scope).
**TEST-04** | MUST | Изменения `web/` — локально `npm run check` и `npm run lint`; CI job `web` в `.gitea/workflows/ci.yaml`.
*Проверка:* локальные команды.
**TEST-05** | MUST | Изменения OpenAPI — `npx @redocly/cli lint docs/openapi.yaml`.
@@ -234,7 +234,9 @@ npx @redocly/cli lint docs/openapi.yaml
# birdfmt: go test ./internal/birdfmt/... -count=1
```
**Рекомендуется (частично внедрено):** CI job `web` (Gitea); `scripts/lint-httpapi.sh` в job `go`; `.golangci.yml` (локально); pre-commit gofmt/prettier.
**CI (Gitea):** job `web` (check + lint); job `go`: `go vet`, `scripts/lint-httpapi.sh` (ARCH-01, ERR-01), `scripts/check-migrations-pair.sh` (DEP-03), `golangci-lint`, `go test -race`, build `cmd/*`.
**Рекомендуется локально:** `.golangci.yml`; `.pre-commit-config.yaml` (gofmt + prettier web).
**Только code review:** слои SQL; роли; idempotency; OpenAPI bodies; secrets в compose.
+24 -5
View File
@@ -2,10 +2,14 @@
name: commit-message
description: >-
ОБЯЗАТЕЛЬНО при commit, коммит, закоммить, commit message, conventional commit,
staged, semantic-release, «сгенерируй коммит», git commit: ПЕРВЫМ делом Shell —
scripts/commit/staged-context.ps1; затем Conventional Commit (заголовок EN, тело RU).
staged, semantic-release, «сгенерируй коммит», git commit, /commit-message: ПЕРВЫМ
делом Shell — scripts/commit/staged-context.ps1; затем Conventional Commit (заголовок EN, тело RU).
---
> **Кнопка ✨ Generate commit message в Source Control** не использует этот скилл и Rules.
> Эквивалент: Agent Chat → **`/commit-message`** или «сгенерируй коммит по staged».
> См. [docs/README.md](../../docs/README.md#сообщения-коммитов-cursor).
# Commit message (EvoBGP)
## Когда применять (сразу читать этот скилл)
@@ -90,9 +94,24 @@ git commit -m "$( @'
По `groups[].diff_excerpt`, `stat`, `files`:
- **type** — по смыслу diff (`feat` / `fix` / …), не по умолчанию `chore`.
### Шаг A — semver (до выбора type)
| Вопрос | Если «да» → |
|--------|-------------|
| Пользователь получает **новую** возможность? | `feat` (minor) |
| Восстанавливается **ожидаемое** поведение / устранён баг? | `fix` (patch) |
| Только скорость, контракт тот же? | `perf` (patch) |
| Только структура кода/UI, поведение то же? | `refactor` (patch) |
**Follow-up:** правки сразу после `feat` в том же scope без новой возможности → **`fix`**, не `feat` (слова *enhance/improve/refactor* в задаче не делают commit `feat`).
**Запрещено** по умолчанию ставить `feat` для «большого diff» в `web/` — type по **semver impact**, не по объёму.
### Шаг B — type, scope, текст
- **type** — результат шага A, не «chore по умолчанию» и не `feat` из-за слова enhance.
- **scope** — из JSON группы или доминирующий при merge.
- **summary** — конкретный, английский, императив.
- **summary** — конкретный, английский, императив; для `fix` — что **починено** (`fix broken …`, `prevent … loop`).
- **body** — русский: что, зачем, edge cases, breaking impact.
## Вывод пользователю
@@ -109,7 +128,7 @@ git commit -m "$( @'
### 2. Пояснение (RU)
- Почему выбран type/scope.
- **Semver impact:** `minor` | `patch` | `none` | `major` — и почему не другой type.
- Риски и impact.
- Split: сколько коммитов и почему.
+25 -37
View File
@@ -1,65 +1,53 @@
# Gitea Actions
Workflow-файлы в [workflows/](workflows/):
Workflow: [workflows/ci.yaml](workflows/ci.yaml).
| Workflow | Триггер | Назначение |
|----------|---------|------------|
| [ci.yaml](workflows/ci.yaml) | push/PR в `main`/`master` | openapi, web, go, bird2, commitlint (PR) |
| [release.yaml](workflows/release.yaml) | после успешного CI (push в main) | semantic-release → tag, CHANGELOG, Gitea Release |
| [publish.yaml](workflows/publish.yaml) | push тега `v*` | docker buildx bake → Container Registry |
| Job | PR | push в main |
|-----|-----|-------------|
| changes, openapi, web, go, bird2 | quality gates | quality gates |
| commitlint | да | — |
| **release** | — | semantic-release + docker push (один run) |
Подробнее о версионировании: [docs/releasing.md](../docs/releasing.md).
Подробнее: [docs/releasing.md](../docs/releasing.md).
## CI (quality gates)
Job **changes** вычисляет флаги по путям в diff (`openapi`, `go`, `web`, `bird_conf`, …). Изменение `.gitea/workflows/*` поднимает полный прогон.
Job **changes** вычисляет флаги по путям в diff. Полный прогон (все узлы openapi / web / go / bird2 в графе): `.gitea/workflows/*`, `scripts/*`, `.golangci.yml`, `.pre-commit-config.yaml`, корневой `package.json` / `.releaserc.json`. Отдельно: `migrations/*`, `docs/openapi.yaml``go` / `openapi` и т.д. (см. `ci.yaml`).
На **pull request** дополнительно запускается **commitlint** (Conventional Commits).
На **pull request** **commitlint** (Conventional Commits).
Требования к runner:
Runner: `ubuntu-latest`, **bird2** из apt, Docker для job **release**.
- Метка `ubuntu-latest`.
- Job `bird2` ставит **bird2** из репозитория Ubuntu и вызывает `bird -c … -p`.
- Job **publish** требует Docker и доступ к Container Registry.
## Release (job в ci.yaml)
## Release (semantic-release)
После успешных quality gates на **push в main** job **release**:
После успешного CI на push в `main`/`master` workflow **Release** запускает `npx semantic-release` (корневой `package.json`, [.releaserc.json](../.releaserc.json)).
1. `npx semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
3. `docker buildx bake default --push` с `VERSION=X.Y.Z` — в том же job.
Результат при releasable-коммитах:
- git tag `vX.Y.Z`;
- commit `CHANGELOG.md` с `[skip ci]`;
- Gitea Release с release notes.
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
### Секреты
Один PAT — **`ACTIONS_PAT`**: push/t/tags, releases, запись в Container Registry. Если не задан — fallback на **`gitea.token`** (нужны права на releases и packages в Gitea).
`GITEA_URL`: `https://git.shts.su` (задано в workflow).
## Publish (образы)
Запускается только при **push git-тега** `vX.Y.Z` (не при каждом push в main).
Собираются все образы из `deploy/docker/*` (`docker buildx bake default --push`). Semver из тега передаётся в Go-бинарники (`VERSION`, `GIT_SHA`, `BUILD_TIME`).
Registry: **`git.shts.su`**. Login: `${{ gitea.actor }}` / `${{ secrets.ACTIONS_PAT || gitea.token }}` (тот же **`ACTIONS_PAT`**, что и для release).
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Fallback: **`gitea.token`**.
### Теги образов
```text
git.shts.su/<owner_lowercase>/<имя>:<тег>
git.shts.su/<owner>/<имя>:latest
git.shts.su/<owner>/<имя>:v1.2.3
git.shts.su/<owner>/<имя>:1.2.3
git.shts.su/<owner>/<имя>:<short-sha>
git.shts.su/<owner>/<имя>:sha-<full-sha>
```
Имена: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
Теги при релизе `v1.2.3`: `latest`, `v1.2.3`, короткий SHA, `sha-<full_commit>`.
Имена образов: `evobgp-api`, `evobgp-all`, `evobgp-scheduler`, `evobgp-ingest`, `evobgp-render`, `evobgp-deploy`, `evobgp-node`, `evobgp-web`, `evobgp-web-all`, `evobgp-agent`, `evobgp-bird2`.
Пример:
```bash
docker pull git.shts.su/myuser/evobgp-api:v1.2.3
docker pull git.shts.su/myuser/evobgp-api:1.2.3
```
Подробности — [docs/quickstart.md](../docs/quickstart.md), [deploy/docker/README.md](../deploy/docker/README.md).
См. [deploy/docker/README.md](../deploy/docker/README.md), [docs/quickstart.md](../docs/quickstart.md).
+169 -37
View File
@@ -8,10 +8,9 @@ on:
jobs:
# ---------------------------------------------------------------------------
# Гранулярная детекция изменений по модулям.
# Каждый флаг соответствует группе файлов; downstream-джобы запускаются
# только когда их группа затронута. Изменение CI-конфигурации (.gitea/workflows/*)
# поднимает все флаги, чтобы гарантировать полный прогон.
# Детекция изменений по модулям (флаги → downstream-джобы в графе CI).
# Полный прогон (все флаги true): .gitea/workflows/*, scripts/*, .golangci.yml,
# .pre-commit-config.yaml — чтобы при правках CI/CD пересобирались все узлы.
# ---------------------------------------------------------------------------
changes:
runs-on: ubuntu-latest
@@ -40,6 +39,23 @@ jobs:
docker_web=false
docker_bird=false
# Все флаги true → openapi, web, go, bird2 (и release на main) в графе CI.
set_all_flags_true() {
openapi=true
go=true
web=true
bird_conf=true
docker_go=true
docker_web=true
docker_bird=true
}
write_outputs() {
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
done
}
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
head="${{ github.event.pull_request.head.sha }}"
@@ -52,61 +68,84 @@ jobs:
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
FILES="$(git diff --name-only HEAD~1 HEAD)"
else
openapi=true; go=true; web=true; bird_conf=true
docker_go=true; docker_web=true; docker_bird=true
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
echo "$v=true" >> "$GITHUB_OUTPUT"
done
echo "No parent commit — full pipeline"
set_all_flags_true
write_outputs
echo "No parent commit — full pipeline (all modules)"
exit 0
fi
fi
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
go=true; web=true
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
done
echo "Empty diff — safe fallback: go=true web=true"
set_all_flags_true
write_outputs
echo "Empty diff — full pipeline fallback"
exit 0
fi
ci_changed=false
full_pipeline=false
while IFS= read -r f || [ -n "${f:-}" ]; do
[ -z "${f:-}" ] && continue
case "$f" in
.gitea/workflows/*) ci_changed=true ;;
docs/openapi.yaml|redocly.yaml) openapi=true ;;
web/README.md) ;; # doc-only
web/*) web=true ;;
deploy/bird/*) bird_conf=true ;;
deploy/docker/bird/*) docker_bird=true; docker_go=true ;;
deploy/docker/gobinary/*) docker_go=true ;;
deploy/docker/docker-bake.hcl) docker_go=true; docker_web=true ;;
deploy/docker/evobgp-agent/*) docker_go=true ;;
deploy/docker/evobgp-web/*) docker_web=true ;;
deploy/docker/bird2/*) docker_bird=true ;;
go.mod|go.sum|go.work) go=true ;;
*.go) go=true ;;
cmd/*|internal/*) go=true ;;
# CI/CD инфраструктура — все узлы quality gates
.gitea/workflows/*|.golangci.yml|.pre-commit-config.yaml|scripts/*)
full_pipeline=true
;;
docs/openapi.yaml|redocly.yaml)
openapi=true
;;
docs/api.md|docs/access.md)
openapi=true
go=true
;;
web/README.md|web/components.json)
;;
web/*)
web=true
;;
deploy/bird/*)
bird_conf=true
go=true
;;
deploy/compose/*|deploy/docker/*)
docker_go=true
docker_web=true
docker_bird=true
go=true
;;
go.mod|go.sum|go.work)
go=true
;;
migrations/*)
go=true
;;
cmd/*|internal/*|*.go)
go=true
bird_conf=true
;;
docs/*)
go=true
;;
package.json|package-lock.json|.releaserc.json)
full_pipeline=true
;;
*)
go=true
;;
esac
done <<< "$FILES"
if $ci_changed; then
go=true; web=true; bird_conf=true
docker_go=true; docker_web=true; docker_bird=true
if $full_pipeline; then
set_all_flags_true
fi
for v in openapi go web bird_conf docker_go docker_web docker_bird; do
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
done
write_outputs
echo "Changed files (first 30):"
printf '%s\n' "$FILES" | head -n 30
echo "--- flags ---"
echo "openapi=$openapi go=$go web=$web bird_conf=$bird_conf"
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird ci=$ci_changed"
echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird full_pipeline=$full_pipeline"
# ---------------------------------------------------------------------------
openapi:
@@ -157,6 +196,14 @@ jobs:
run: go vet ./...
- name: Lint httpapi (ERR-01 / ARCH-01)
run: sh scripts/lint-httpapi.sh
- name: Check migration pairs (DEP-03)
run: sh scripts/check-migrations-pair.sh
# go.mod: go 1.24 — бинарник golangci-lint < v1.64.2 (сборка на Go 1.23) не запускается.
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: v1.64.8
install-mode: goinstall
- name: Test
run: go test ./... -race -count=1
- name: Build all commands
@@ -225,3 +272,88 @@ jobs:
set -euxo pipefail
npm ci
npx commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}"
# ---------------------------------------------------------------------------
# Один push в main: semantic-release (тег на текущий commit, без доп. commit) + docker push.
# ---------------------------------------------------------------------------
release:
needs: [changes, openapi, web, go, bird2]
if: >-
always() &&
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
needs.changes.result == 'success' &&
(needs.openapi.result == 'success' || needs.openapi.result == 'skipped') &&
(needs.web.result == 'success' || needs.web.result == 'skipped') &&
(needs.go.result == 'success' || needs.go.result == 'skipped') &&
(needs.bird2.result == 'success' || needs.bird2.result == 'skipped')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
persist-credentials: true
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: package-lock.json
- name: Install release tooling
run: npm ci
- name: Semantic release
run: npx semantic-release
env:
GITEA_URL: https://git.shts.su
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
- name: Detect new release
id: rel
run: |
set -euo pipefail
if [ -f .release-version ]; then
echo "version=$(tr -d '[:space:]' < .release-version)" >> "$GITHUB_OUTPUT"
echo "released=true" >> "$GITHUB_OUTPUT"
else
echo "released=false" >> "$GITHUB_OUTPUT"
echo "No releasable commits — skipping image publish"
fi
- name: Set up Docker Buildx
if: steps.rel.outputs.released == 'true'
uses: docker/setup-buildx-action@v3
- name: Prepare image metadata
if: steps.rel.outputs.released == 'true'
id: meta
run: |
set -euo pipefail
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Log in to Gitea Registry
if: steps.rel.outputs.released == 'true'
uses: docker/login-action@v3
with:
registry: git.shts.su
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
- name: Build and push images (bake)
if: steps.rel.outputs.released == 'true'
env:
REGISTRY: git.shts.su/${{ steps.meta.outputs.owner_lc }}
IMAGE_TAG: latest
VERSION: ${{ steps.meta.outputs.version }}
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
SHA_FULL: ${{ github.sha }}
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
CACHE_REF_GO: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
CACHE_REF_WEB: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
run: |
set -euxo pipefail
cd "${{ github.workspace }}/deploy/docker"
sh write-bake-override.sh
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
-f docker-bake.hcl -f docker-bake.override.hcl default --push
-48
View File
@@ -1,48 +0,0 @@
name: Publish
on:
push:
tags: ["v*"]
jobs:
publish-images:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Prepare image metadata
id: meta
run: |
set -euo pipefail
ref="${GITHUB_REF_NAME:-}"
version="${ref#v}"
echo "version=$version" >> "$GITHUB_OUTPUT"
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Log in to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.shts.su
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT || gitea.token }}
- name: Build and push images (bake)
env:
REGISTRY: git.shts.su/${{ steps.meta.outputs.owner_lc }}
IMAGE_TAG: latest
VERSION: ${{ steps.meta.outputs.version }}
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
SHA_FULL: ${{ github.sha }}
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
CACHE_REF_GO: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:go-buildcache
CACHE_REF_WEB: git.shts.su/${{ steps.meta.outputs.owner_lc }}/evobgp-buildcache:web-buildcache
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
run: |
set -euxo pipefail
cd "${{ github.workspace }}/deploy/docker"
sh write-bake-override.sh
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
-f docker-bake.hcl -f docker-bake.override.hcl default --push
-33
View File
@@ -1,33 +0,0 @@
name: Release
on:
workflow_run:
workflows: [CI]
types: [completed]
branches: [main, master]
jobs:
release:
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
persist-credentials: true
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: package-lock.json
- name: Install release tooling
run: npm ci
- name: Semantic release
run: npx semantic-release
env:
GITEA_URL: https://git.shts.su
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
+4
View File
@@ -4,6 +4,10 @@ node_modules/
# Generated by deploy/docker/write-bake-override.sh (CI/local bake)
deploy/docker/docker-bake.override.hcl
# CI-only markers (not committed)
.release-version
CHANGELOG.md
# OS
.DS_Store
Thumbs.db
+1
View File
@@ -2,6 +2,7 @@ run:
timeout: 5m
linters:
disable-all: true
enable:
- gofmt
- govet
+19
View File
@@ -0,0 +1,19 @@
# Local hooks (optional): install with `pre-commit install`
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- repo: local
hooks:
- id: prettier-web
name: prettier (web)
entry: bash -c 'cd web && npx prettier --check .'
language: system
files: ^web/
pass_filenames: false
+4 -3
View File
@@ -10,6 +10,8 @@
{ "type": "feat", "release": "minor" },
{ "type": "fix", "release": "patch" },
{ "type": "perf", "release": "patch" },
{ "type": "ci", "release": "patch" },
{ "type": "refactor", "release": "patch" },
{ "breaking": true, "release": "major" }
]
}
@@ -27,10 +29,9 @@
}
],
[
"@semantic-release/git",
"@semantic-release/exec",
{
"assets": ["CHANGELOG.md"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
"successCmd": "echo ${nextRelease.version} > .release-version"
}
],
[
+3 -1
View File
@@ -38,13 +38,15 @@
## Коммиты (Conventional Commits)
Если пользователь просит **коммит**, **commit message**, **закоммить**, **git commit** или это следует из плана — **сразу**:
Если пользователь просит **коммит**, **commit message**, **закоммить**, **git commit**, **`/commit-message`** или это следует из плана — **сразу**:
1. Shell: `powershell -NoProfile -File scripts/commit/staged-context.ps1` (первый вызов, до текста коммита).
2. Скилл [.cursor/skills/commit-message/SKILL.md](.cursor/skills/commit-message/SKILL.md) и правило [.cursor/rules/conventional-commits.mdc](.cursor/rules/conventional-commits.mdc).
Без вывода скрипта (exit 0) **не** придумывать сообщение коммита. Заголовок — EN, тело — RU; несвязанные области — auto-split (скилл).
**Кнопка ✨ Generate commit message в Source Control** skill/rule **не** использует. Для сообщений по правилам EvoBGP — Agent Chat → **`/commit-message`** (см. [.cursor/commands/commit-message.md](.cursor/commands/commit-message.md)).
## Команды и среда
- Консоль пользователя: **PowerShell**; пути в стиле `deploy\compose`.
+1 -1
View File
@@ -50,6 +50,6 @@ docker compose --profile reference up -d
- [docs/releasing.md](docs/releasing.md) — пайплайн, commit conventions, секреты CI
- API: `GET /version` и `GET /v1/version` (поле `version`)
- Docker-образы публикуются по git-тегу `vX.Y.Z` (workflow **Publish**)
- Docker-образы: теги `latest`, `vX.Y.Z`, `X.Y.Z` — в **том же CI run**, что и релиз (job `release`)
Лицензия и условия использования — по политике владельца репозитория.
+7 -2
View File
@@ -55,8 +55,12 @@ func main() {
startBirdMetricsPoller(ctx)
httpSrv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: srv.Handler(),
Addr: cfg.HTTPAddr,
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 60 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
svc := platform.ServiceName("evobgp-all")
@@ -91,6 +95,7 @@ func startBirdMetricsPoller(ctx context.Context) {
return birdfmt.ShowProtocols(ctx, socket, birdcBin)
},
birdfmt.CountEstablishedBGPSessions,
birdfmt.ParseBGPProtocolStates,
)
log.Printf("birdc protocols poller enabled (socket=%s interval=%s)", sock, interval)
}
+8 -3
View File
@@ -42,8 +42,12 @@ func main() {
startBirdMetricsPoller(ctx)
httpSrv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: srv.Handler(),
Addr: cfg.HTTPAddr,
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 60 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
svc := platform.ServiceName("evobgp-api")
@@ -53,7 +57,7 @@ func main() {
tid, mCDN, mIP, rev, sp := srv.Store().DemoIDs()
log.Printf("demo tenant=%s module_cdn=%s module_ip_ranges=%s revision=%s speaker=%s", tid, mCDN, mIP, rev, sp)
log.Printf("example: EVOBGP_API_KEYS=op|%s|operator,node|%s|node", tid, tid)
log.Printf("with EVOBGP_DEV_INSECURE=1 use Authorization: Bearer dev (operator, demo tenant only)")
log.Printf("demo auth: Authorization: Bearer dev (operator, demo tenant only)")
}
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
@@ -84,6 +88,7 @@ func startBirdMetricsPoller(ctx context.Context) {
return birdfmt.ShowProtocols(ctx, socket, birdcBin)
},
birdfmt.CountEstablishedBGPSessions,
birdfmt.ParseBGPProtocolStates,
)
log.Printf("birdc protocols poller enabled (socket=%s interval=%s)", sock, interval)
}
+1 -1
View File
@@ -96,7 +96,7 @@ services:
<<: *env-ref
EVOBGP_HTTP_ADDR: ":8080"
EVOBGP_SEED_DEMO: "1"
# Local reference only: allows Bearer dev for scheduler HTTP client (EVOBGP_SCHEDULER_BEARER).
# DEV ONLY — не для production. Bearer dev + слабые demo-секреты (см. docs/access.md).
EVOBGP_DEV_INSECURE: "1"
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
EVOBGP_BIRDC_INTERVAL: 30s
+1
View File
@@ -135,6 +135,7 @@ services:
EVOBGP_BIRDC_INTERVAL: 30s
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
# DEV ONLY — не для production (см. docs/access.md).
EVOBGP_DEV_INSECURE: "1"
volumes:
- bird_etc:/etc/bird
+1 -1
View File
@@ -4,7 +4,7 @@
Сборка образов — **`docker buildx bake`** (`deploy/docker/docker-bake.hcl`), не отдельные `docker build`.
**Publish:** push git-тега `vX.Y.Z` запускает [.gitea/workflows/publish.yaml](../.gitea/workflows/publish.yaml) — образы с semver-тегом, `latest`, `sha-*`. Переменная `VERSION` из тега попадает в Go-бинарники через `-ldflags`.
**Publish:** push в `main` после quality gates — job **release** в [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml): semantic-release + bake с `VERSION` из релиза.
Локально BuildKit также кэширует `/go/pkg/mod` и `~/.cache/go-build` через `RUN --mount=type=cache`.
+4 -1
View File
@@ -149,7 +149,10 @@ function "image-tags" {
"${REGISTRY}/${name}:${IMAGE_TAG}",
"${REGISTRY}/${name}:${SHORT_SHA}",
],
notequal(VERSION, "dev") && notequal(VERSION, "") ? ["${REGISTRY}/${name}:v${VERSION}"] : []
notequal(VERSION, "dev") && notequal(VERSION, "") ? [
"${REGISTRY}/${name}:v${VERSION}",
"${REGISTRY}/${name}:${VERSION}",
] : []
)
}
+8 -2
View File
@@ -5,8 +5,14 @@ server {
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
# Docker embedded DNS: без resolver nginx кэширует IP upstream при старте —
# после recreate evobgp-all остаётся 502 (connection refused на старый IP).
resolver 127.0.0.11 valid=10s ipv6=off;
set $evobgp_upstream evobgp-api;
location /v1/ {
proxy_pass http://evobgp-api:8080/v1/;
# С переменной в proxy_pass нельзя полагаться на замену URI — передаём $request_uri целиком.
proxy_pass http://$evobgp_upstream:8080$request_uri;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -15,7 +21,7 @@ server {
}
location = /metrics {
proxy_pass http://evobgp-api:8080/metrics;
proxy_pass http://$evobgp_upstream:8080/metrics;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
+10 -1
View File
@@ -36,7 +36,16 @@
### Сообщения коммитов (Cursor)
После `git add` попросите агента: **«сгенерируй коммит по staged»**, **«закоммить»**, **«commit message»** — агент **обязан первым делом** запустить `scripts/commit/staged-context.ps1`, затем скилл [commit-message](../.cursor/skills/commit-message/SKILL.md) (заголовок EN, тело RU, auto-split). Просмотр групп вручную: `powershell -NoProfile -File scripts/commit/staged-context.ps1 | ConvertFrom-Json`.
После `git add`:
| Способ | Skill + rule + `staged-context.ps1` |
|--------|-------------------------------------|
| Agent → **`/commit-message`** или «сгенерируй коммит по staged» | **Да** |
| Кнопка **✨ Generate commit message** в Source Control | **Нет** (только diff + история; [ограничение Cursor](https://forum.cursor.com/t/how-to-set-prompt-for-generate-commit-message/148606)) |
Рекомендуемый workflow: Agent Chat → **`/commit-message`** ([команда](../.cursor/commands/commit-message.md), [скилл](../.cursor/skills/commit-message/SKILL.md), [правило](../.cursor/rules/conventional-commits.mdc)). Агент **обязан первым делом** запустить `scripts/commit/staged-context.ps1` (заголовок EN, тело RU, auto-split).
Просмотр групп вручную: `powershell -NoProfile -File scripts/commit/staged-context.ps1 | ConvertFrom-Json`.
## Репозиторий и CI
+22 -3
View File
@@ -22,6 +22,19 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
При включённом демо-сиде сервер при старте может вывести в лог готовую подсказку с реальным `tenant_id` из БД — см. лог `evobgp-api` / `evobgp-all`.
Ключи из `EVOBGP_API_KEYS` загружаются при старте и **дополняют** ключи из таблицы `api_key` в БД (break-glass / bootstrap). После первого operator-ключа можно создавать остальные через API или веб-настройки.
### Управление через API и UI
При подключённой БД operator может:
- `GET|POST /v1/api-keys`, `GET|PATCH|DELETE /v1/api-keys/{id}`, `POST /v1/api-keys/{id}/rotate` — см. OpenAPI, тег **API keys**.
- В веб-панели: **Права доступа** (`/access`) → блок «API-ключи» (только для роли `operator`). Токен для браузера — в **Настройки** (`/settings`).
Полный токен возвращается **один раз** в ответе `201` (создание) и `200` (ротация). В списках — только `prefix` (первые 8 символов). В БД хранится SHA-256 токена, не plaintext.
`GET /v1/auth/session` — текущие `tenant_id` и `role` (для UI).
### Роли
| Роль | Уровень | Назначение |
@@ -33,11 +46,16 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
Обратное ограничение: для эндпоинтов ноды требуется именно роль **`node`**; остальные роли получают отказ.
### Режим разработки `EVOBGP_DEV_INSECURE`
### Токен `dev` (локальная разработка)
Если установлено `EVOBGP_DEV_INSECURE=1` и в store доступен демо-tenant (`DemoIDs`), то запрос с заголовком **`Authorization: Bearer dev`** получает контекст **`operator`** для этого tenant.
Если в store доступен демо-tenant (`DemoIDs`, обычно `EVOBGP_SEED_DEMO` не равен `0`), заголовок **`Authorization: Bearer dev`** даёт роль **`operator`** для этого tenant. **Не зависит** от `EVOBGP_DEV_INSECURE`.
**Запрещено** в продакшене: любой, кто знает заголовок, получает полные права оператора на демо-данные.
**Запрещено** в продакшене: не оставляйте demo-seed с известным токеном `dev` на боевых данных. Переменная `EVOBGP_DEV_INSECURE` в текущей версии **не влияет** на аутентификацию (оставлена в compose для совместимости; не включайте в production — см. SEC-02 в инженерных правилах).
### Синхронные «тяжёлые» GET (control plane)
- `POST /v1/modules/{module_id}/cdn-sources/preview` — загрузка CDN в том же HTTP-запросе (лимит тела ~8 MiB, см. OpenAPI).
- `GET /v1/bird/status` (если маршрут включён в деплое) — опрос локального `birdc`, таймаут сервера ~12 с.
### Детерминированный ключ подписи бандлов (тесты)
@@ -88,6 +106,7 @@ http://localhost:5173,http://127.0.0.1:5173,https://ui.example.com
| GET модули, ревизии, peers, speakers | да | да | да | нет |
| POST/PATCH/DELETE CRUD сущностей | нет | да | да | нет |
| apply, rollback, PATCH settings | нет | нет | да | нет |
| Управление API-ключами (`/v1/api-keys`) | нет | нет | да | нет |
| bundle, latest revision, enroll | нет | нет | нет | да |
Точные проверки по каждому маршруту — в коде `internal/httpapi` и в схеме безопасности операций в OpenAPI.
+6
View File
@@ -44,6 +44,12 @@
- `GET|POST /v1/communities`
- `GET|PATCH|DELETE /v1/communities/{id}`
### API keys
- `GET /v1/auth/session` — tenant и роль текущего ключа
- `GET|POST /v1/api-keys` — список и создание (operator)
- `GET|PATCH|DELETE /v1/api-keys/{id}`, `POST /v1/api-keys/{id}/rotate`
### Peers
- `GET /v1/peers`, `POST /v1/peers`
+256 -2
View File
@@ -45,6 +45,10 @@ tags:
description: "API для evobgp-node (бандлы ревизий и enrollment). Отдельный ключ или mTLS, роль node."
- name: Settings
description: Глобальные настройки и feature flags; изменение - только operator.
- name: API keys
description: Управление API-ключами tenant (operator). Секрет возвращается только при создании и ротации.
- name: Auth
description: Сессия текущего API-ключа (tenant и роль).
security:
- bearerAuth: []
@@ -67,7 +71,9 @@ components:
required: false
schema:
$ref: "#/components/schemas/ResourceId"
description: Явный tenant (только супер-роли). Без заголовка tenant определяется по ключу.
description: >
Явный tenant (только супер-роли). Без заголовка tenant определяется по API-ключу.
**Реализация v1:** заголовок в Go handlers не обрабатывается; tenant только из Bearer-токена (см. docs/access.md).
IdempotencyKey:
name: Idempotency-Key
in: header
@@ -133,6 +139,12 @@ components:
required: true
schema:
$ref: "#/components/schemas/ResourceId"
ApiKeyId:
name: id
in: path
required: true
schema:
$ref: "#/components/schemas/ResourceId"
SourceId:
name: source_id
in: path
@@ -637,6 +649,82 @@ components:
vault_secret_ref:
type: ["string", "null"]
AuthSession:
type: object
required: [tenant_id, role]
properties:
tenant_id:
$ref: "#/components/schemas/ResourceId"
role:
type: string
enum: [viewer, editor, operator, node]
ApiKey:
type: object
required: [id, name, role, prefix, created_at, updated_at]
properties:
id:
$ref: "#/components/schemas/ResourceId"
name:
type: string
role:
type: string
enum: [viewer, editor, operator, node]
prefix:
type: string
description: Первые 8 символов токена для идентификации в UI.
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
expires_at:
type: ["string", "null"]
format: date-time
revoked_at:
type: ["string", "null"]
format: date-time
last_used_at:
type: ["string", "null"]
format: date-time
additionalProperties: true
ApiKeyCreate:
type: object
required: [name, role]
properties:
name:
type: string
role:
type: string
enum: [viewer, editor, operator, node]
expires_at:
type: ["string", "null"]
format: date-time
ApiKeyPatch:
type: object
properties:
name:
type: string
role:
type: string
enum: [viewer, editor, operator, node]
expires_at:
type: ["string", "null"]
format: date-time
ApiKeyCreated:
allOf:
- $ref: "#/components/schemas/ApiKey"
- type: object
required: [token]
properties:
token:
type: string
description: Полный Bearer-токен; показывается один раз.
BgpCommunity:
type: object
required:
@@ -1811,7 +1899,9 @@ paths:
tags: [Modules]
summary: Предпросмотр префиксов из CDN-источника
description: >
Загружает URL, парсит как plaintext или json и возвращает список извлечённых префиксов (до 100 записей).
Синхронный запрос: conditional GET к URL (до 8 MiB тела ответа), парсинг plaintext или JSON,
возврат до 100 префиксов в `items` (полный счётчик в `total`). Выполняется в HTTP worker;
при таймауте клиента используйте короткий URL или меньший payload.
operationId: previewCdnSource
requestBody:
required: true
@@ -2639,6 +2729,170 @@ paths:
default:
$ref: "#/components/responses/DefaultProblem"
/v1/auth/session:
get:
tags: [Auth]
summary: Текущая сессия API-ключа
operationId: getAuthSession
parameters:
- $ref: "#/components/parameters/TenantId"
responses:
"200":
description: Успешно.
content:
application/json:
schema:
$ref: "#/components/schemas/AuthSession"
"401":
$ref: "#/components/responses/Unauthorized"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/api-keys:
get:
tags: [API keys]
summary: Список API-ключей tenant
description: Только роль **operator**. Секреты не возвращаются.
operationId: listApiKeys
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/Cursor"
- $ref: "#/components/parameters/Limit"
responses:
"200":
description: Успешно.
content:
application/json:
schema:
type: object
required: [items, has_more]
properties:
items:
type: array
items:
$ref: "#/components/schemas/ApiKey"
next_cursor:
type: ["string", "null"]
has_more:
type: boolean
"403":
$ref: "#/components/responses/Forbidden"
default:
$ref: "#/components/responses/DefaultProblem"
post:
tags: [API keys]
summary: Создать API-ключ
operationId: createApiKey
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ApiKeyCreate"
responses:
"201":
description: Ключ создан; token в ответе один раз.
content:
application/json:
schema:
$ref: "#/components/schemas/ApiKeyCreated"
"403":
$ref: "#/components/responses/Forbidden"
"422":
$ref: "#/components/responses/UnprocessableEntity"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/api-keys/{id}:
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/ApiKeyId"
get:
tags: [API keys]
summary: Получить метаданные API-ключа
operationId: getApiKey
responses:
"200":
description: Успешно.
content:
application/json:
schema:
$ref: "#/components/schemas/ApiKey"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
default:
$ref: "#/components/responses/DefaultProblem"
patch:
tags: [API keys]
summary: Обновить API-ключ
operationId: patchApiKey
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ApiKeyPatch"
responses:
"200":
description: Успешно.
content:
application/json:
schema:
$ref: "#/components/schemas/ApiKey"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
default:
$ref: "#/components/responses/DefaultProblem"
delete:
tags: [API keys]
summary: Отозвать API-ключ
operationId: revokeApiKey
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"204":
description: Отозван.
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/api-keys/{id}/rotate:
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/ApiKeyId"
post:
tags: [API keys]
summary: Ротировать секрет API-ключа
description: Выдаёт новый token; старый перестаёт работать сразу.
operationId: rotateApiKey
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"200":
description: Успешно.
content:
application/json:
schema:
$ref: "#/components/schemas/ApiKeyCreated"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/settings:
get:
tags: [Settings]
+30 -35
View File
@@ -7,47 +7,49 @@ EvoBGP использует [Conventional Commits](https://www.conventionalcommi
| Тип коммита | Bump |
|-------------|------|
| `feat` | minor (1.0.0 → 1.1.0) |
| `fix`, `perf` | patch (1.0.0 → 1.0.1) |
| `fix`, `perf`, `ci`, `refactor` | patch (1.5.1 → 1.5.2) |
| `feat!`, `fix!` или `BREAKING CHANGE:` в теле | major (1.0.0 → 2.0.0) |
| `docs`, `chore`, `ci`, `test`, `refactor` | без релиза |
| `docs`, `chore`, `test` | без релиза |
`refactor` — patch без новых функций: перестройка кода/UI при том же поведении для пользователя. По semver на одном уровне с `fix`, но семантически «мельче» `feat` (не minor).
Отдельного суффикса `1.x.y.fix` в semver нет: «fix» в Conventional Commits означает **patch** (третья цифра). Для починки пайплайна без смены продукта — `fix(ci):` или `ci:` (оба дают patch после настройки `.releaserc.json`).
Первый релиз при отсутствии git-тегов — **1.0.0**, если есть releasable-коммиты.
**Как не перепутать `feat` и `fix`:** см. раздел «Выбор type: semver, а не «красивые слова»» в [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc). Кратко: `feat` — новая возможность (minor); `fix` — починка ожидаемого поведения (patch); follow-up баги после недавнего `feat` — всегда `fix`, даже если diff большой.
Подробные правила сообщений коммитов: [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc).
## CI-пайплайн
## CI-пайплайн (один push в main)
```text
push/merge в main
→ CI (openapi, web, go, bird2, commitlint на PR)
Release (semantic-release после успешного CI)
git tag vX.Y.Z
CHANGELOG.md + commit [skip ci]
Gitea Release с notes
→ Publish (push тега v*)
→ docker buildx bake с VERSION из тега
→ образы: latest, vX.Y.Z, sha-*, короткий SHA
→ CI: openapi, web, go, bird2 (параллельно)
job release (в том же workflow, после quality gates):
semantic-release: git tag vX.Y.Z на текущий commit (без доп. commit)
Gitea Release + CHANGELOG.md как attachment
docker buildx bake с VERSION=X.Y.Z
→ образы: latest, vX.Y.Z, X.Y.Z, sha-*, короткий SHA
```
Workflow-файлы:
Pull request: только quality gates + commitlint; релиз и образы **не** публикуются.
- [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml) — quality gates
- [.gitea/workflows/release.yaml](../.gitea/workflows/release.yaml) — semantic-release
- [.gitea/workflows/publish.yaml](../.gitea/workflows/publish.yaml) — публикация образов
Workflow: [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml) (job **release**).
Конфиг semantic-release: [.releaserc.json](../.releaserc.json).
Конфиг semantic-release: [.releaserc.json](../.releaserc.json) — без `@semantic-release/git` (CHANGELOG не коммитится в репозиторий).
## Секреты Gitea
Один PAT в репозитории**`ACTIONS_PAT`** (Settings → Actions → Secrets). Используется для semantic-release, push тегов/CHANGELOG и docker login в registry.
Один PAT — **`ACTIONS_PAT`** (Settings → Actions → Secrets).
| Право PAT | Зачем |
|-----------|--------|
| push / write repository | commit `CHANGELOG.md`, push тегов |
| releases | Gitea Release через semantic-release |
| packages (Container Registry) | workflow **Publish** |
| push tags | git tag `vX.Y.Z` на commit merge |
| releases | Gitea Release + notes |
| packages (Container Registry) | push образов |
Если `ACTIONS_PAT` не задан, workflow пробует **`gitea.token`** job-токен (нужны права на releases и packages в настройках Gitea).
Fallback: **`gitea.token`** (нужны права на releases и packages).
## Источник правды для версии в runtime
@@ -56,28 +58,21 @@ Semver из git-тега пробрасывается в Go-бинарники
- локально (`go run`) — `version: "dev"`
- в образе после релиза — совпадает с тегом (например `1.2.3`)
API:
- `GET /version`
- `GET /v1/version`
Ответ включает `version`, `git_sha`, `build_time`; поле `api_version` — deprecated alias.
API: `GET /version`, `GET /v1/version` — поля `version`, `git_sha`, `build_time`.
Web UI показывает версию из API (footer sidebar, страница «Мониторинг»).
## CHANGELOG
Файл [`CHANGELOG.md`](../CHANGELOG.md) создаётся и обновляется semantic-release. Копия прикрепляется к Gitea Release.
Release notes — в Gitea Release; файл `CHANGELOG.md` генерируется в CI и прикрепляется как asset, **не** попадает в git history.
## Проверка после релиза
1. В Gitea: тег `vX.Y.Z` и Release с notes.
2. Container Registry: образы с тегом `vX.Y.Z`.
3. `curl http://localhost:8080/version``"version":"X.Y.Z"`.
4. Footer Web UI → `vX.Y.Z`.
1. Один run workflow **CI** на push в main: job **release** зелёный.
2. Gitea: тег `vX.Y.Z` на том же commit, что и merge; Release с notes.
3. Container Registry: `evobgp-api:vX.Y.Z`, `evobgp-api:X.Y.Z`, `evobgp-api:latest`.
4. `curl http://localhost:8080/version``"version":"X.Y.Z"`.
## Первый релиз (bootstrap)
Merge PR в `main` с conventional commit типа `feat(release): ...` (не `chore` — иначе релиз не создастся). Ожидаемый результат: **v1.0.0**.
После merge убедитесь, что workflow **Release** завершился успешно и workflow **Publish** собрал образы по тегу.
Merge в `main` с `feat(release): ...`**v1.0.0** в том же CI run.
+4 -4
View File
@@ -42,7 +42,7 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
if err != nil {
return nil, fmt.Errorf("ripestat fetch AS%d: %w", asn, err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil {
return nil, err
@@ -52,8 +52,8 @@ func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip
}
var wrap struct {
Status string `json:"status"`
Data struct {
Status string `json:"status"`
Data struct {
Prefixes []struct {
Prefix string `json:"prefix"`
} `json:"prefixes"`
@@ -104,7 +104,7 @@ func ASHolderName(ctx context.Context, hc *http.Client, asn int64) (string, erro
if err != nil {
return "", fmt.Errorf("ripestat as-overview AS%d: %w", asn, err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return "", err
+34
View File
@@ -0,0 +1,34 @@
// Package authkey generates API tokens and derives lookup hashes (no persistence).
package authkey
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
)
const tokenPrefix = "evobgp_"
// GenerateToken returns a new bearer token (evobgp_ + 32 random bytes, base64url).
func GenerateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("authkey: generate token: %w", err)
}
return tokenPrefix + base64.RawURLEncoding.EncodeToString(b), nil
}
// HashToken returns SHA-256 of the full token (32 bytes).
func HashToken(token string) []byte {
sum := sha256.Sum256([]byte(token))
return sum[:]
}
// Prefix returns the first 8 characters of the token for display.
func Prefix(token string) string {
if len(token) <= 8 {
return token
}
return token[:8]
}
+4 -4
View File
@@ -60,10 +60,10 @@ func RenderBGPTemplates(opts BGPTemplatesOptions) (string, error) {
// BGPPeerFromTemplateOptions describes protocol bgp NAME from TEMPLATE { … }.
type BGPPeerFromTemplateOptions struct {
ProtocolName string
TemplateName string
NeighborIP string
NeighborASN uint32
ProtocolName string
TemplateName string
NeighborIP string
NeighborASN uint32
// If set, emits "local … as …" before neighbor (overrides template local/ASN for this peer).
OverrideLocalIP string
OverrideLocalASN uint32
+17 -6
View File
@@ -9,6 +9,21 @@ import (
const maxBGPASN = 4294967295
const filterPrefixChunkSize = 500
func writePrefixSetAcceptBlocks(b *strings.Builder, keys []string) {
for i := 0; i < len(keys); i += filterPrefixChunkSize {
end := i + filterPrefixChunkSize
if end > len(keys) {
end = len(keys)
}
chunk := keys[i:end]
b.WriteString(" if net ~ [ ")
b.WriteString(strings.Join(chunk, ", "))
b.WriteString(" ] then accept;\n")
}
}
func filterUniqueASNs(pathASNs []int64) []int64 {
seen := make(map[int64]struct{})
for _, a := range pathASNs {
@@ -52,9 +67,7 @@ func RenderExportFilterIPv4(filterName string, prefixes []netip.Prefix, pathASNs
b.WriteString(strings.TrimSpace(filterName))
b.WriteString(" {\n")
if len(keys) > 0 {
b.WriteString(" if net ~ [ ")
b.WriteString(strings.Join(keys, ", "))
b.WriteString(" ] then accept;\n")
writePrefixSetAcceptBlocks(&b, keys)
}
for _, asn := range asns {
fmt.Fprintf(&b, " if bgp_path ~ [= * %d =] then accept;\n", asn)
@@ -95,9 +108,7 @@ func RenderExportFilterIPv6(filterName string, prefixes []netip.Prefix, pathASNs
b.WriteString(strings.TrimSpace(filterName))
b.WriteString(" {\n")
if len(keys) > 0 {
b.WriteString(" if net ~ [ ")
b.WriteString(strings.Join(keys, ", "))
b.WriteString(" ] then accept;\n")
writePrefixSetAcceptBlocks(&b, keys)
}
for _, asn := range asns {
fmt.Fprintf(&b, " if bgp_path ~ [= * %d =] then accept;\n", asn)
+45
View File
@@ -50,3 +50,48 @@ func CountEstablishedBGPSessions(showProtocolsOutput string) int {
}
return n
}
// ParseBGPProtocolStates parses `birdc show protocols all` summary rows into protocol_name -> state.
func ParseBGPProtocolStates(output string) map[string]string {
out := make(map[string]string)
for _, raw := range strings.Split(output, "\n") {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
low := strings.ToLower(line)
if strings.HasPrefix(low, "bird ") || strings.HasPrefix(low, "name ") || strings.HasPrefix(low, "table ") {
continue
}
fields := strings.Fields(line)
if len(fields) < 4 {
continue
}
if !strings.EqualFold(fields[1], "BGP") {
continue
}
state := extractBGPSessionStateLine(line)
if state == "" {
state = fields[3]
}
out[fields[0]] = state
}
return out
}
func extractBGPSessionStateLine(line string) string {
known := []string{
"Established",
"Idle",
"Connect",
"Active",
"OpenSent",
"OpenConfirm",
}
for _, st := range known {
if strings.Contains(line, st) {
return st
}
}
return ""
}
+6 -6
View File
@@ -17,12 +17,12 @@ import (
// Manifest describes bundle contents for evobgp-node verification.
type Manifest struct {
RevisionID string `json:"revision_id"`
SpeakerID string `json:"speaker_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
Files []FileEntry `json:"files"`
Algorithm string `json:"signature_algorithm"`
PublicKeyB64 string `json:"public_key_base64"`
RevisionID string `json:"revision_id"`
SpeakerID string `json:"speaker_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
Files []FileEntry `json:"files"`
Algorithm string `json:"signature_algorithm"`
PublicKeyB64 string `json:"public_key_base64"`
}
// FileEntry is one file inside the bundle archive.
+1 -1
View File
@@ -27,7 +27,7 @@ func VerifyGzippedTar(bundle []byte, pub ed25519.PublicKey) (*VerifiedContents,
if err != nil {
return nil, err
}
defer gr.Close()
defer func() { _ = gr.Close() }()
var manifestRaw []byte
var sig []byte
+14
View File
@@ -6,9 +6,12 @@ import (
"errors"
"fmt"
"io/fs"
"os"
"path"
"sort"
"strconv"
"strings"
"time"
"evobgp/migrations"
@@ -22,6 +25,17 @@ func OpenPostgresPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
if err != nil {
return nil, err
}
if max := os.Getenv("EVOBGP_DB_MAX_CONNS"); max != "" {
if n, err := strconv.Atoi(strings.TrimSpace(max)); err == nil && n > 0 {
cfg.MaxConns = int32(n)
}
}
if min := os.Getenv("EVOBGP_DB_MIN_CONNS"); min != "" {
if n, err := strconv.Atoi(strings.TrimSpace(min)); err == nil && n >= 0 {
cfg.MinConns = int32(n)
}
}
cfg.MaxConnLifetime = 30 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, err
+66
View File
@@ -0,0 +1,66 @@
package httpapi
import (
"crypto/sha256"
"encoding/hex"
"sync"
"evobgp/internal/store"
)
type apiKeyResolver struct {
mu sync.RWMutex
envByToken map[string]apiKeyRecord
byHash map[string]apiKeyRecord
}
func newAPIKeyResolver(envSpec string, st store.Backend) (*apiKeyResolver, error) {
r := &apiKeyResolver{
envByToken: make(map[string]apiKeyRecord),
byHash: make(map[string]apiKeyRecord),
}
for _, rec := range parseAPIKeysSpec(envSpec) {
r.envByToken[rec.token] = rec
}
return r, r.reloadFromStore(st)
}
func (r *apiKeyResolver) reloadFromStore(st store.Backend) error {
rows, err := st.ListActiveAPIKeyHashes()
if err != nil {
return err
}
byHash := make(map[string]apiKeyRecord, len(rows))
for _, row := range rows {
if len(row.TokenHash) != 32 {
continue
}
byHash[hex.EncodeToString(row.TokenHash)] = apiKeyRecord{
token: "",
tenantID: row.TenantID,
role: row.Role,
keyID: row.ID,
}
}
r.mu.Lock()
r.byHash = byHash
r.mu.Unlock()
return nil
}
func (r *apiKeyResolver) Reload(st store.Backend) error {
return r.reloadFromStore(st)
}
func (r *apiKeyResolver) Lookup(raw string) (apiKeyRecord, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
if rec, ok := r.envByToken[raw]; ok {
return rec, true
}
sum := sha256.Sum256([]byte(raw))
key := hex.EncodeToString(sum[:])
rec, ok := r.byHash[key]
return rec, ok
}
+13 -21
View File
@@ -15,6 +15,7 @@ type Auth struct {
TenantID string
Role string // viewer, editor, operator, node
Token string
APIKeyID string // non-empty for DB-managed keys
}
func authFromContext(ctx context.Context) (Auth, bool) {
@@ -26,6 +27,7 @@ type apiKeyRecord struct {
token string
tenantID string
role string
keyID string // set for DB-managed keys (last_used_at)
}
func parseAPIKeysSpec(spec string) []apiKeyRecord {
@@ -54,20 +56,6 @@ func parseAPIKeysSpec(spec string) []apiKeyRecord {
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.insecureDev {
h := r.Header.Get("Authorization")
const p = "Bearer "
if strings.HasPrefix(h, p) {
tok := strings.TrimSpace(strings.TrimPrefix(h, p))
if tok == "dev" {
if a, ok := s.devAuth(); ok {
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
return
}
}
}
}
h := r.Header.Get("Authorization")
const p = "Bearer "
if !strings.HasPrefix(h, p) {
@@ -75,18 +63,22 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
return
}
raw := strings.TrimSpace(strings.TrimPrefix(h, p))
var matched *apiKeyRecord
for i := range s.apiKeys {
if s.apiKeys[i].token == raw {
matched = &s.apiKeys[i]
break
if raw == "dev" {
if a, ok := s.devAuth(); ok {
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
return
}
}
if matched == nil {
matched, ok := s.keyResolver.Lookup(raw)
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key")
return
}
a := Auth{TenantID: matched.tenantID, Role: matched.role, Token: raw}
a := Auth{TenantID: matched.tenantID, Role: matched.role, Token: raw, APIKeyID: matched.keyID}
if matched.keyID != "" {
go func(id string) { _ = s.store.TouchAPIKeyLastUsed(id) }(matched.keyID)
}
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
})
+15
View File
@@ -53,6 +53,21 @@ func BootstrapWorkers(ctx context.Context, opts Options) (store.Backend, *jobs.R
wk := &jobs.Worker{Store: backend, HTTPClient: cdnHTTP}
reg := jobs.NewRegistry(wk.Process)
wk.Registry = reg
if pool != nil {
audit := repository.NewJobAuditWriter(pool)
reg.SetTerminalHook(func(j *jobs.Job) {
if j == nil {
return
}
st := j.Snapshot()
status, _ := st["status"].(string)
var errMsg *string
if e, ok := st["error"].(string); ok && e != "" {
errMsg = &e
}
audit.MarkTerminal(context.Background(), j.TenantID, j.ID, status, errMsg, time.Now().UTC())
})
}
observability.RegisterStoreBackend(backend)
return backend, reg, pool, nil
}
+14
View File
@@ -3,6 +3,8 @@ package httpapi
import (
"net/http"
"strconv"
"evobgp/internal/store"
)
func parseListLimit(r *http.Request) int {
@@ -22,3 +24,15 @@ func strPtrOrNull(s string) any {
}
return s
}
// writePaginatedListJSON returns a cursor/limit page as OpenAPI list envelopes (items, next_cursor, has_more).
func writePaginatedListJSON[T any](w http.ResponseWriter, r *http.Request, all []T, toItem func(T) map[string]any) {
page, next, more := store.PaginateOffset(all, r.URL.Query().Get("cursor"), parseListLimit(r))
items := make([]map[string]any, 0, len(page))
for _, x := range page {
items = append(items, toItem(x))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
})
}
+9 -5
View File
@@ -9,15 +9,19 @@ import (
const (
internalErrorDetail = "an internal error occurred"
badGatewayDetail = "upstream request failed"
notFoundDetail = "resource not found"
invalidInputDetail = "invalid request data"
cdnExtractDetail = "could not extract prefixes from source"
csvInvalidRowDetail = "invalid row in csv file"
)
// Problem is RFC 9457 application/problem+json.
type Problem struct {
Type string `json:"type,omitempty"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
Type string `json:"type,omitempty"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
}
func writeProblem(w http.ResponseWriter, status int, title, detail string) {
+82 -8
View File
@@ -69,6 +69,7 @@ func (s *Server) registerV1(m *http.ServeMux) {
m.HandleFunc("GET /bird/status", s.handleBirdStatus)
m.HandleFunc("GET /jobs", s.handleListJobs)
m.HandleFunc("GET /jobs/{job_id}", s.handleGetJob)
m.HandleFunc("GET /jobs/{job_id}/report", s.handleGetJobReport)
m.HandleFunc("POST /jobs/{job_id}/cancel", s.handleCancelJob)
m.HandleFunc("GET /speakers/{speaker_id}/revisions/latest", s.handleNodeLatestRevision)
m.HandleFunc("GET /speakers/{speaker_id}/bundle/{revision_id}", s.handleNodeBundle)
@@ -84,12 +85,15 @@ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
checks := map[string]string{"store": "ok", "jobs": "memory"}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if s.pgPool != nil {
if err := s.pgPool.Ping(ctx); err != nil {
if err := s.store.Ping(ctx); err != nil {
checks["store"] = "unavailable"
if s.pgPool != nil {
checks["postgres"] = "unavailable"
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready", "checks": checks})
return
}
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready", "checks": checks})
return
}
if s.pgPool != nil {
checks["postgres"] = "ok"
} else {
checks["store_backend"] = "memory"
@@ -198,6 +202,22 @@ func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
}
filtered := make([]*store.Module, 0)
limit := parseListLimit(r)
cursor := r.URL.Query().Get("cursor")
if typeFilter == "" && enabledFilter == nil {
page, next, more := s.store.ListModulesPage(a.TenantID, cursor, limit)
for _, mod := range page {
filtered = append(filtered, mod)
}
items := make([]map[string]any, 0, len(filtered))
for _, mod := range filtered {
items = append(items, moduleJSON(mod))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
})
return
}
for _, mod := range s.store.ListModules(a.TenantID) {
if typeFilter != "" && mod.Type != typeFilter {
continue
@@ -272,7 +292,7 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
}
allPeers := s.store.ListPeers(a.TenantID)
page, next, more := store.PaginateOffset(allPeers, r.URL.Query().Get("cursor"), parseListLimit(r))
liveStates := s.liveBGPProtocolStates(r.Context())
liveStates := s.liveBGPProtocolStates(r)
items := make([]map[string]any, 0, len(page))
for _, p := range page {
row := peerJSON(p)
@@ -286,7 +306,17 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
})
}
func (s *Server) liveBGPProtocolStates(ctx context.Context) map[string]string {
func (s *Server) liveBGPProtocolStates(r *http.Request) map[string]string {
if r != nil && strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("live")), "1") {
return s.liveBGPProtocolStatesFresh(r.Context())
}
if cached, ok := observability.CachedBirdProtocolStates(90 * time.Second); ok {
return cached
}
return s.liveBGPProtocolStatesFresh(r.Context())
}
func (s *Server) liveBGPProtocolStatesFresh(ctx context.Context) map[string]string {
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
if sock == "" {
return map[string]string{}
@@ -295,7 +325,9 @@ func (s *Server) liveBGPProtocolStates(ctx context.Context) map[string]string {
if err != nil {
return map[string]string{}
}
return parseBGPProtocolStates(out)
states := birdfmt.ParseBGPProtocolStates(out)
observability.SetBirdProtocolStates(states)
return states
}
// parseBGPProtocolStates parses `birdc show protocols all` summary rows into protocol_name -> state.
@@ -519,7 +551,8 @@ func (s *Server) enqueueModuleRefreshIfEnabled(tenantID, moduleID, trigger strin
return
}
mid := moduleID
_, _, _ = s.jobs.Enqueue(tenantID, jobs.KindModuleRefresh, nil, &mid, map[string]any{
key := "module_refresh:" + moduleID
_, _, _ = s.jobs.Enqueue(tenantID, jobs.KindModuleRefresh, &key, &mid, map[string]any{
"module_id": moduleID,
"trigger": trigger,
})
@@ -586,6 +619,9 @@ func (s *Server) handleRevisionPreview(w http.ResponseWriter, r *http.Request) {
for k, v := range rev.PreviewFragments {
obj[k] = v
}
if expanded := pipeline.BuildExpandedBirdPreview(rev.PreviewFragments); expanded != "" {
obj[pipeline.AuxBirdFullExpandedKey()] = expanded
}
writeJSON(w, http.StatusOK, obj)
}
@@ -840,6 +876,44 @@ func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, j.Snapshot())
}
func (s *Server) handleGetJobReport(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
j, err := s.jobs.Get(a.TenantID, r.PathValue("job_id"))
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "job not found")
return
}
snap := j.Snapshot()
meta, _ := snap["meta"].(map[string]any)
out := map[string]any{
"job_id": snap["job_id"],
"kind": snap["kind"],
"status": snap["status"],
"meta": meta,
"error": snap["error"],
"created_at": snap["created_at"],
}
if meta != nil {
if v, ok := meta["log_entries"]; ok {
out["log_entries"] = v
}
if v, ok := meta["log_total"]; ok {
out["log_total"] = v
}
if v, ok := meta["revision_id"]; ok {
out["revision_id"] = v
}
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
+217
View File
@@ -0,0 +1,217 @@
package httpapi
import (
"encoding/json"
"net/http"
"strings"
"time"
"evobgp/internal/store"
)
func (s *Server) registerAPIKeyRoutes(m *http.ServeMux) {
m.HandleFunc("GET /auth/session", s.handleAuthSession)
m.HandleFunc("GET /api-keys", s.handleListAPIKeys)
m.HandleFunc("POST /api-keys", s.handlePostAPIKey)
m.HandleFunc("GET /api-keys/{id}", s.handleGetAPIKey)
m.HandleFunc("PATCH /api-keys/{id}", s.handlePatchAPIKey)
m.HandleFunc("DELETE /api-keys/{id}", s.handleDeleteAPIKey)
m.HandleFunc("POST /api-keys/{id}/rotate", s.handleRotateAPIKey)
}
func (s *Server) handleAuthSession(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
writeJSON(w, http.StatusOK, map[string]any{
"tenant_id": a.TenantID,
"role": a.Role,
})
}
func apiKeyJSON(k *store.APIKey) map[string]any {
m := map[string]any{
"id": k.ID,
"name": k.Name,
"role": k.Role,
"prefix": k.Prefix,
"created_at": k.CreatedAt.UTC().Format(time.RFC3339),
"updated_at": k.UpdatedAt.UTC().Format(time.RFC3339),
}
if k.ExpiresAt != nil {
m["expires_at"] = k.ExpiresAt.UTC().Format(time.RFC3339)
} else {
m["expires_at"] = nil
}
if k.RevokedAt != nil {
m["revoked_at"] = k.RevokedAt.UTC().Format(time.RFC3339)
} else {
m["revoked_at"] = nil
}
if k.LastUsedAt != nil {
m["last_used_at"] = k.LastUsedAt.UTC().Format(time.RFC3339)
} else {
m["last_used_at"] = nil
}
return m
}
func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
list, err := s.store.ListAPIKeys(a.TenantID)
if err != nil {
writeStoreErr(w, err)
return
}
writePaginatedListJSON(w, r, list, func(k *store.APIKey) map[string]any {
return apiKeyJSON(k)
})
}
func (s *Server) handleGetAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
k, err := s.store.GetAPIKey(a.TenantID, r.PathValue("id"))
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, apiKeyJSON(k))
}
func (s *Server) handlePostAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
var body struct {
Name string `json:"name"`
Role string `json:"role"`
ExpiresAt *string `json:"expires_at"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
in := &store.APIKeyCreate{
Name: strings.TrimSpace(body.Name),
Role: strings.TrimSpace(body.Role),
}
if body.ExpiresAt != nil && strings.TrimSpace(*body.ExpiresAt) != "" {
t, err := time.Parse(time.RFC3339, strings.TrimSpace(*body.ExpiresAt))
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid expires_at")
return
}
in.ExpiresAt = &t
}
created, err := s.store.CreateAPIKey(a.TenantID, in)
if err != nil {
writeStoreErr(w, err)
return
}
if err := s.keyResolver.Reload(s.store); err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
return
}
out := apiKeyJSON(&created.APIKey)
out["token"] = created.Token
writeJSON(w, http.StatusCreated, out)
}
func (s *Server) handlePatchAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
var raw map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
patch := &store.APIKeyPatch{}
if v, ok := raw["name"]; ok {
var name string
if err := json.Unmarshal(v, &name); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid name")
return
}
patch.Name = &name
}
if v, ok := raw["role"]; ok {
var role string
if err := json.Unmarshal(v, &role); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid role")
return
}
patch.Role = &role
}
if v, ok := raw["expires_at"]; ok {
if string(v) == "null" {
patch.ClearExpiresAt = true
} else {
var s string
if err := json.Unmarshal(v, &s); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid expires_at")
return
}
t, err := time.Parse(time.RFC3339, strings.TrimSpace(s))
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid expires_at")
return
}
patch.ExpiresAt = &t
}
}
k, err := s.store.UpdateAPIKey(a.TenantID, r.PathValue("id"), patch)
if err != nil {
writeStoreErr(w, err)
return
}
if err := s.keyResolver.Reload(s.store); err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
return
}
writeJSON(w, http.StatusOK, apiKeyJSON(k))
}
func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
if err := s.store.RevokeAPIKey(a.TenantID, r.PathValue("id")); err != nil {
writeStoreErr(w, err)
return
}
if err := s.keyResolver.Reload(s.store); err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleRotateAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
rotated, err := s.store.RotateAPIKey(a.TenantID, r.PathValue("id"))
if err != nil {
writeStoreErr(w, err)
return
}
if err := s.keyResolver.Reload(s.store); err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
return
}
out := apiKeyJSON(&rotated.APIKey)
out["token"] = rotated.Token
writeJSON(w, http.StatusOK, out)
}
+119
View File
@@ -0,0 +1,119 @@
package httpapi
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestBearerDevWithoutInsecureDev(t *testing.T) {
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules?limit=1", nil)
req.Header.Set("Authorization", "Bearer dev")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
}
}
func TestAPIKeysCRUDAndAuth(t *testing.T) {
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, _ := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "opkey|"+tenant+"|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
client := ts.Client()
base := ts.URL
reqCreate, _ := http.NewRequest(http.MethodPost, base+"/v1/api-keys", strings.NewReader(`{"name":"ci","role":"editor"}`))
reqCreate.Header.Set("Authorization", "Bearer opkey")
reqCreate.Header.Set("Content-Type", "application/json")
respCreate, err := client.Do(reqCreate)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respCreate.Body.Close() }()
if respCreate.StatusCode != http.StatusCreated {
b, _ := io.ReadAll(respCreate.Body)
t.Fatalf("create status=%d body=%s", respCreate.StatusCode, b)
}
var created map[string]any
if err := json.NewDecoder(respCreate.Body).Decode(&created); err != nil {
t.Fatal(err)
}
token, _ := created["token"].(string)
if token == "" {
t.Fatal("missing token in create response")
}
id, _ := created["id"].(string)
if id == "" {
t.Fatal("missing id")
}
reqMod, _ := http.NewRequest(http.MethodGet, base+"/v1/modules?limit=1", nil)
reqMod.Header.Set("Authorization", "Bearer "+token)
respMod, err := client.Do(reqMod)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respMod.Body.Close() }()
if respMod.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respMod.Body)
t.Fatalf("modules status=%d body=%s", respMod.StatusCode, b)
}
reqDel, _ := http.NewRequest(http.MethodDelete, base+"/v1/api-keys/"+id, nil)
reqDel.Header.Set("Authorization", "Bearer opkey")
respDel, err := client.Do(reqDel)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respDel.Body.Close() }()
if respDel.StatusCode != http.StatusNoContent {
t.Fatalf("delete status=%d", respDel.StatusCode)
}
reqAfter, _ := http.NewRequest(http.MethodGet, base+"/v1/modules?limit=1", nil)
reqAfter.Header.Set("Authorization", "Bearer "+token)
respAfter, err := client.Do(reqAfter)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respAfter.Body.Close() }()
if respAfter.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401 after revoke, got %d", respAfter.StatusCode)
}
mustSetTestAPIKeys(t, srv, "nodekey|"+tenant+"|node,opkey|"+tenant+"|operator")
reqNode2, _ := http.NewRequest(http.MethodGet, base+"/v1/api-keys", nil)
reqNode2.Header.Set("Authorization", "Bearer nodekey")
respNode, err := client.Do(reqNode2)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respNode.Body.Close() }()
if respNode.StatusCode != http.StatusForbidden {
t.Fatalf("node list api-keys status=%d want 403", respNode.StatusCode)
}
}
+17 -26
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
@@ -69,6 +70,8 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
m.HandleFunc("GET /settings", s.handleGetSettings)
m.HandleFunc("PATCH /settings", s.handlePatchSettings)
s.registerAPIKeyRoutes(m)
}
func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
@@ -172,12 +175,15 @@ func (s *Server) handleDeleteModule(w http.ResponseWriter, r *http.Request) {
}
func writeStoreErr(w http.ResponseWriter, err error) {
if err != nil {
log.Printf("httpapi: store: %v", err)
}
if err == store.ErrNotFound || err == store.ErrTenantScope {
writeProblem(w, http.StatusNotFound, "Not Found", err.Error())
writeProblem(w, http.StatusNotFound, "Not Found", notFoundDetail)
return
}
if err == store.ErrInvalidInput {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", invalidInputDetail)
return
}
writeInternalError(w, "store", err)
@@ -193,11 +199,7 @@ func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
items := make([]map[string]any, 0, len(list))
for _, x := range list {
items = append(items, cdnSourceJSON(x))
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "next_cursor": nil, "has_more": false})
writePaginatedListJSON(w, r, list, cdnSourceJSON)
}
func cdnSourceJSON(x *store.CDNSource) map[string]any {
@@ -258,7 +260,7 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
writeBadGateway(w, "cdn preview fetch", err)
return
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
writeBadGateway(w, "cdn preview fetch", fmt.Errorf("upstream status: %s", resp.Status))
@@ -271,7 +273,8 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
}
pfxs, err := pipeline.ExtractCIDRs(string(raw), body.SourceKind, body.PrefixPath)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
log.Printf("httpapi: cdn preview extract: %v", err)
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", cdnExtractDetail)
return
}
items := make([]string, 0, len(pfxs))
@@ -354,11 +357,7 @@ func (s *Server) handleListAS(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
items := make([]map[string]any, 0, len(list))
for _, x := range list {
items = append(items, asEntryJSON(x))
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
writePaginatedListJSON(w, r, list, asEntryJSON)
}
func asEntryJSON(x *store.ASEntry) map[string]any {
@@ -450,11 +449,7 @@ func (s *Server) handleListDomain(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
items := make([]map[string]any, 0, len(list))
for _, x := range list {
items = append(items, domainEntryJSON(x))
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
writePaginatedListJSON(w, r, list, domainEntryJSON)
}
func domainEntryJSON(x *store.DomainEntry) map[string]any {
@@ -531,11 +526,7 @@ func (s *Server) handleListIPRange(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
items := make([]map[string]any, 0, len(list))
for _, x := range list {
items = append(items, ipRangeJSON(x))
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
writePaginatedListJSON(w, r, list, ipRangeJSON)
}
func ipRangeJSON(x *store.IPRangeEntry) map[string]any {
@@ -702,8 +693,8 @@ func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Req
return
}
if strings.Contains(err.Error(), "importer: line") {
detail := strings.TrimPrefix(err.Error(), "importer: ")
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", detail)
log.Printf("httpapi: csv import: %v", err)
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", csvInvalidRowDetail)
return
}
if strings.Contains(err.Error(), "importer: csv import/export") {
+4 -4
View File
@@ -21,7 +21,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
defer srv.Close()
tenant, _, modIP, _, _ := srv.Store().DemoIDs()
srv.apiKeys = parseAPIKeysSpec("opkey|" + tenant + "|operator")
mustSetTestAPIKeys(t, srv, "opkey|"+tenant+"|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
@@ -34,7 +34,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer respList.Body.Close()
defer func() { _ = respList.Body.Close() }()
if respList.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respList.Body)
t.Fatalf("communities status %d: %s", respList.StatusCode, b)
@@ -59,7 +59,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer respImport.Body.Close()
defer func() { _ = respImport.Body.Close() }()
if respImport.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respImport.Body)
t.Fatalf("import status %d: %s", respImport.StatusCode, b)
@@ -80,7 +80,7 @@ func TestModuleEntriesCSVImportExportIPRanges(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer respExport.Body.Close()
defer func() { _ = respExport.Body.Close() }()
if respExport.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respExport.Body)
t.Fatalf("export status %d: %s", respExport.StatusCode, b)
+87
View File
@@ -0,0 +1,87 @@
package httpapi
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNestedModuleListPagination(t *testing.T) {
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, modIP, _, _ := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|editor")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
client := ts.Client()
base := ts.URL
mid := modIP
for i := 0; i < 3; i++ {
body := strings.NewReader(fmt.Sprintf(`{"prefix":"10.%d.0.0/24"}`, 200+i))
req, _ := http.NewRequest(http.MethodPost, base+"/v1/modules/"+mid+"/ip-range-entries", body)
req.Header.Set("Authorization", "Bearer edkey")
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create entry %d: status %d", i, resp.StatusCode)
}
}
req, _ := http.NewRequest(http.MethodGet, base+"/v1/modules/"+mid+"/ip-range-entries?limit=2", nil)
req.Header.Set("Authorization", "Bearer edkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("list status %d: %s", resp.StatusCode, b)
}
var page1 struct {
Items []map[string]any `json:"items"`
NextCursor *string `json:"next_cursor"`
HasMore bool `json:"has_more"`
}
if err := json.NewDecoder(resp.Body).Decode(&page1); err != nil {
t.Fatal(err)
}
if len(page1.Items) != 2 {
t.Fatalf("page1 items: got %d want 2", len(page1.Items))
}
if !page1.HasMore || page1.NextCursor == nil || *page1.NextCursor == "" {
t.Fatalf("page1: has_more=%v next_cursor=%v", page1.HasMore, page1.NextCursor)
}
req2, _ := http.NewRequest(http.MethodGet, base+"/v1/modules/"+mid+"/ip-range-entries?limit=2&cursor="+*page1.NextCursor, nil)
req2.Header.Set("Authorization", "Bearer edkey")
resp2, err := client.Do(req2)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp2.Body.Close() }()
var page2 struct {
Items []map[string]any `json:"items"`
HasMore bool `json:"has_more"`
}
if err := json.NewDecoder(resp2.Body).Decode(&page2); err != nil {
t.Fatal(err)
}
if len(page1.Items)+len(page2.Items) < 3 {
t.Fatalf("expected at least 3 entries across pages, got %d+%d", len(page1.Items), len(page2.Items))
}
}
+10 -8
View File
@@ -21,8 +21,7 @@ type Server struct {
pgPool *pgxpool.Pool
jobs *jobs.Registry
bundlePriv ed25519.PrivateKey
apiKeys []apiKeyRecord
insecureDev bool
keyResolver *apiKeyResolver
corsOrigins []string
cdnHTTP *http.Client
mux *http.ServeMux
@@ -32,10 +31,10 @@ type Server struct {
type Options struct {
APIKeys string
// DatabaseURL enables PostgreSQL-backed store (migrations applied on connect).
DatabaseURL string
InsecureDev bool
SeedDemo bool
BundleSeedHex string
DatabaseURL string
InsecureDev bool
SeedDemo bool
BundleSeedHex string
CORSAllowedOrigins string
}
@@ -60,13 +59,16 @@ func New(opts Options) (*Server, error) {
_, priv, _ = ed25519.GenerateKey(rand.Reader)
}
resolver, err := newAPIKeyResolver(opts.APIKeys, backend)
if err != nil {
return nil, err
}
s := &Server{
store: backend,
pgPool: pool,
jobs: reg,
bundlePriv: priv,
apiKeys: parseAPIKeysSpec(opts.APIKeys),
insecureDev: opts.InsecureDev && opts.SeedDemo,
keyResolver: resolver,
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
cdnHTTP: NewCDNHTTPClient(),
}
+15 -15
View File
@@ -29,7 +29,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
}
defer srv.Close()
tenant, modCDN, modIP, rev, speaker := srv.Store().DemoIDs()
srv.apiKeys = parseAPIKeysSpec("nodekey|" + tenant + "|node,opkey|" + tenant + "|operator,edkey|" + tenant + "|editor")
mustSetTestAPIKeys(t, srv, "nodekey|"+tenant+"|node,opkey|"+tenant+"|operator,edkey|"+tenant+"|editor")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
@@ -51,7 +51,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -76,7 +76,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -97,7 +97,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -118,7 +118,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -132,7 +132,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -162,7 +162,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s status %d: %s", path, resp.StatusCode, b)
}
@@ -185,7 +185,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -215,7 +215,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -255,7 +255,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusForbidden {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d want 403: %s", resp.StatusCode, b)
@@ -269,7 +269,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -289,7 +289,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -314,7 +314,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
@@ -341,7 +341,7 @@ func waitJob(t *testing.T, client *http.Client, base, token, jobID string) {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
_ = resp.Body.Close()
var body struct {
Status string `json:"status"`
}
@@ -405,7 +405,7 @@ func TestVersionEndpoints(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
+12
View File
@@ -0,0 +1,12 @@
package httpapi
import "testing"
func mustSetTestAPIKeys(t *testing.T, srv *Server, spec string) {
t.Helper()
resolver, err := newAPIKeyResolver(spec, srv.store)
if err != nil {
t.Fatal(err)
}
srv.keyResolver = resolver
}
+77
View File
@@ -0,0 +1,77 @@
package jobs
import (
"context"
"os"
"strconv"
"time"
)
const (
defaultJobTimeoutModuleRefresh = 10 * time.Minute
defaultJobTimeoutTenantRefresh = 15 * time.Minute
defaultJobTimeoutDeployApply = 5 * time.Minute
defaultJobTimeoutPeerReconcile = 10 * time.Minute
defaultJobTimeoutRollback = 5 * time.Minute
defaultJobTimeoutBirdReload = 2 * time.Minute
)
func jobTimeout(kind string) time.Duration {
envKey := map[string]string{
KindModuleRefresh: "EVOBGP_JOB_TIMEOUT_MODULE_REFRESH",
KindTenantRefresh: "EVOBGP_JOB_TIMEOUT_TENANT_REFRESH",
KindDeployApply: "EVOBGP_JOB_TIMEOUT_DEPLOY_APPLY",
KindPeerReconcile: "EVOBGP_JOB_TIMEOUT_PEER_RECONCILE",
KindRevisionRollback: "EVOBGP_JOB_TIMEOUT_ROLLBACK",
KindBirdReload: "EVOBGP_JOB_TIMEOUT_BIRD_RELOAD",
}[kind]
if envKey != "" {
if d, err := time.ParseDuration(os.Getenv(envKey)); err == nil && d > 0 {
return d
}
}
switch kind {
case KindModuleRefresh:
return defaultJobTimeoutModuleRefresh
case KindTenantRefresh:
return defaultJobTimeoutTenantRefresh
case KindDeployApply:
return defaultJobTimeoutDeployApply
case KindPeerReconcile:
return defaultJobTimeoutPeerReconcile
case KindRevisionRollback:
return defaultJobTimeoutRollback
case KindBirdReload:
return defaultJobTimeoutBirdReload
default:
if n, err := strconv.Atoi(os.Getenv("EVOBGP_JOB_TIMEOUT_SEC")); err == nil && n > 0 {
return time.Duration(n) * time.Second
}
return defaultJobTimeoutModuleRefresh
}
}
// workContext returns a timeout context that also cancels when the job is cancelled.
func (j *Job) workContext() (context.Context, context.CancelFunc) {
if j == nil {
return context.Background(), func() {}
}
timeout := jobTimeout(j.Kind)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
go func() {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if j.IsCancelRequested() {
cancel()
return
}
}
}
}()
return ctx, cancel
}
+51 -2
View File
@@ -9,6 +9,8 @@ import (
"sync"
"time"
"evobgp/internal/observability"
"github.com/google/uuid"
)
@@ -178,6 +180,8 @@ type Registry struct {
byID map[string]*Job
byIdempo map[idempoKey]*Job
workerStart func(j *Job)
workerSem chan struct{}
onTerminal func(j *Job)
}
type idempoKey struct {
@@ -186,13 +190,44 @@ type idempoKey struct {
}
func NewRegistry(workerStart func(j *Job)) *Registry {
maxWorkers := registryMaxConcurrentJobs()
return &Registry{
byID: make(map[string]*Job),
byIdempo: make(map[idempoKey]*Job),
workerStart: workerStart,
workerSem: make(chan struct{}, maxWorkers),
}
}
// SetTerminalHook registers a best-effort callback when jobs reach a terminal state.
func (r *Registry) SetTerminalHook(fn func(j *Job)) {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.onTerminal = fn
}
func (r *Registry) fireTerminal(j *Job) {
if r == nil || j == nil {
return
}
r.mu.RLock()
fn := r.onTerminal
r.mu.RUnlock()
if fn != nil {
fn(j)
}
}
func registryMaxConcurrentJobs() int {
if n, err := strconv.Atoi(strings.TrimSpace(os.Getenv("EVOBGP_JOB_MAX_CONCURRENT"))); err == nil && n > 0 {
return n
}
return 8
}
// pruneTerminalIfOver удаляет самые старые завершённые джобы (succeeded/failed/cancelled), пока len(byID) > maxJobs.
func (r *Registry) pruneTerminalIfOver(maxJobs int) {
if r == nil || maxJobs <= 0 || len(r.byID) <= maxJobs {
@@ -244,7 +279,11 @@ func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, module
if idempotencyKey != nil && *idempotencyKey != "" {
k := idempoKey{tenant: tenantID, key: *idempotencyKey}
if existing, ok := r.byIdempo[k]; ok {
return existing, false, nil
st := existing.statusLocked()
if st == StatusQueued || st == StatusRunning {
return existing, false, nil
}
delete(r.byIdempo, k)
}
}
@@ -265,7 +304,17 @@ func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, module
r.pruneTerminalIfOver(maxJobs)
if r.workerStart != nil {
go r.workerStart(j)
go func() {
r.workerSem <- struct{}{}
active := len(r.workerSem)
capacity := cap(r.workerSem)
observability.RecordJobQueueDepth(active, capacity)
defer func() {
<-r.workerSem
observability.RecordJobQueueDepth(len(r.workerSem), capacity)
}()
r.workerStart(j)
}()
}
return j, true, nil
}
+56 -8
View File
@@ -82,6 +82,9 @@ func (w *Worker) httpClient() *http.Client {
func (w *Worker) Process(j *Job) {
defer func() {
observability.RecordJobTerminal(j.Kind, j.statusLocked())
if w != nil && w.Registry != nil {
w.Registry.fireTerminal(j)
}
}()
if w == nil || w.Store == nil {
@@ -102,7 +105,17 @@ func (w *Worker) Process(j *Job) {
j.Fail("missing module_id in job meta")
return
}
if err := pipeline.RefreshModuleIngest(context.Background(), w.Store, w.httpClient(), j.TenantID, mid); err != nil {
ctx, cancel := j.workContext()
defer cancel()
if ctx.Err() != nil {
j.MarkCancelled()
return
}
if err := pipeline.RefreshModuleIngest(ctx, w.Store, w.httpClient(), j.TenantID, mid); err != nil {
if ctx.Err() != nil {
j.MarkCancelled()
return
}
j.Fail(err.Error())
return
}
@@ -121,11 +134,17 @@ func (w *Worker) Process(j *Job) {
j.Succeed()
return
}
ctx, cancel := j.workContext()
defer cancel()
ctl := &birdfmt.BirdCtl{
Socket: sock,
Birdc: strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN")),
}
if err := ctl.Configure(context.Background()); err != nil {
if err := ctl.Configure(ctx); err != nil {
if ctx.Err() != nil {
j.MarkCancelled()
return
}
j.Fail(err.Error())
return
}
@@ -152,9 +171,14 @@ func (w *Worker) runPeerReconcile(j *Job) {
return
}
if len(latest) == 0 {
// First run fallback: render full tenant state once if no baseline revision exists yet.
rid, err := pipeline.RenderTenantRevision(context.Background(), w.Store, w.httpClient(), j.TenantID, triggerModuleID)
ctx, cancel := j.workContext()
defer cancel()
rid, err := pipeline.RenderTenantRevision(ctx, w.Store, w.httpClient(), j.TenantID, triggerModuleID)
if err != nil {
if ctx.Err() != nil {
j.MarkCancelled()
return
}
j.Fail(err.Error())
return
}
@@ -171,8 +195,14 @@ func (w *Worker) runPeerReconcile(j *Job) {
}
cursor = next
}
rid, err := pipeline.RenderTenantRevisionFromPrefixes(context.Background(), w.Store, w.httpClient(), j.TenantID, triggerModuleID, rows)
ctx, cancel := j.workContext()
defer cancel()
rid, err := pipeline.RenderTenantRevisionFromPrefixes(ctx, w.Store, w.httpClient(), j.TenantID, triggerModuleID, rows)
if err != nil {
if ctx.Err() != nil {
j.MarkCancelled()
return
}
j.Fail(err.Error())
return
}
@@ -230,7 +260,13 @@ func (w *Worker) runTenantRefresh(j *Job) {
j.Fail("missing module_ids in job meta")
return
}
if err := pipeline.RefreshTenantModules(context.Background(), w.Store, w.httpClient(), j.TenantID, moduleIDs); err != nil {
ctx, cancel := j.workContext()
defer cancel()
if err := pipeline.RefreshTenantModules(ctx, w.Store, w.httpClient(), j.TenantID, moduleIDs); err != nil {
if ctx.Err() != nil {
j.MarkCancelled()
return
}
j.Fail(err.Error())
return
}
@@ -275,6 +311,8 @@ func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
mu := w.tenantRefreshMu(j.TenantID)
mu.Lock()
defer mu.Unlock()
ctx, cancel := j.workContext()
defer cancel()
deferDeploy := false
if w.Registry != nil {
deferDeploy = w.Registry.CountOtherActiveRefresh(j.TenantID, j.ID) > 0
@@ -288,8 +326,12 @@ func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
return
}
rev, err := pipeline.RenderTenantRevision(context.Background(), w.Store, w.httpClient(), j.TenantID, triggerModuleID)
rev, err := pipeline.RenderTenantRevision(ctx, w.Store, w.httpClient(), j.TenantID, triggerModuleID)
if err != nil {
if ctx.Err() != nil {
j.MarkCancelled()
return
}
j.Fail(err.Error())
return
}
@@ -335,6 +377,8 @@ func (w *Worker) runDeployApply(j *Job) {
j.Fail("missing revision_id in job meta")
return
}
ctx, cancel := j.workContext()
defer cancel()
activeDir := strings.TrimSpace(os.Getenv("EVOBGP_BIRD_ACTIVE_DIR"))
if activeDir != "" {
revObj, err := w.Store.GetRevision(j.TenantID, revID)
@@ -354,7 +398,11 @@ func (w *Worker) runDeployApply(j *Job) {
Socket: strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET")),
}
ctl := &birdfmt.BirdCtl{Bird: cfg.BirdBin, Birdc: cfg.BirdcBin, Socket: cfg.Socket}
if err := birddeploy.ApplyRevision(context.Background(), ctl, revObj, cfg); err != nil {
if err := birddeploy.ApplyRevision(ctx, ctl, revObj, cfg); err != nil {
if ctx.Err() != nil {
j.MarkCancelled()
return
}
j.Fail(err.Error())
return
}
+2 -2
View File
@@ -66,7 +66,7 @@ func fetchLatestRevision(base, token, speaker string) (string, error) {
if err != nil {
return "", err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("latest revision: %s: %s", resp.Status, strings.TrimSpace(string(b)))
@@ -94,7 +94,7 @@ func fetchBundle(base, token, speaker, revision string) ([]byte, error) {
if err != nil {
return nil, err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("bundle: %s: %s", resp.Status, strings.TrimSpace(string(b)))
+1 -1
View File
@@ -27,7 +27,7 @@ func Run(args []string) int {
// Usage prints CLI help to w.
func Usage(w interface{ Write([]byte) (int, error) }) {
fmt.Fprintf(w, `Usage:
_, _ = fmt.Fprintf(w, `Usage:
evobgp-node pull-bundle -base-url URL -token TOKEN -speaker-id ID [-revision-id ID] [-o path]
evobgp-node verify-bundle -f bundle.tar.gz (-pubkey-base64 B64 | -pubkey-hex HEX)
evobgp-node apply-bundle -f bundle.tar.gz -extract-dir DIR (-pubkey-base64 B64 | -pubkey-hex HEX)
+86 -1
View File
@@ -80,6 +80,38 @@ var (
Help: "Prefix row count after CIDR aggregation on tenant render.",
Buckets: prometheus.ExponentialBuckets(1, 2, 16),
})
pipelineRefreshDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Namespace: namespace,
Name: "pipeline_refresh_duration_seconds",
Help: "Module refresh ingest duration by module type.",
Buckets: prometheus.ExponentialBuckets(0.05, 2, 14),
}, []string{"module_type"})
renderPrefixCount = promauto.NewHistogram(prometheus.HistogramOpts{
Namespace: namespace,
Name: "render_prefix_count",
Help: "Materialized prefix count per tenant render.",
Buckets: prometheus.ExponentialBuckets(10, 2, 16),
})
jobQueueActive = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: namespace,
Name: "job_queue_active",
Help: "Currently running in-process async jobs.",
})
jobQueueCapacity = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: namespace,
Name: "job_queue_capacity",
Help: "Maximum concurrent in-process async jobs.",
})
)
var (
birdProtocolStatesMu sync.RWMutex
birdProtocolStates map[string]string
birdProtocolStatesAt time.Time
)
// RecordPrefixAggregation records tenant render CIDR aggregation stats.
@@ -93,6 +125,27 @@ func RecordPrefixAggregation(rawCount, aggregatedCount int, duration time.Durati
prefixAggregationDuration.Observe(duration.Seconds())
prefixAggregationRawCount.Observe(float64(rawCount))
prefixAggregationAggregatedCount.Observe(float64(aggregatedCount))
renderPrefixCount.Observe(float64(aggregatedCount))
}
// RecordPipelineRefresh records module ingest duration.
func RecordPipelineRefresh(moduleType string, duration time.Duration) {
if moduleType == "" {
moduleType = "unknown"
}
pipelineRefreshDuration.WithLabelValues(moduleType).Observe(duration.Seconds())
}
// RecordJobQueueDepth updates in-process job worker utilization gauges.
func RecordJobQueueDepth(active, capacity int) {
if active < 0 {
active = 0
}
if capacity < 0 {
capacity = 0
}
jobQueueActive.Set(float64(active))
jobQueueCapacity.Set(float64(capacity))
}
// RecordJobTerminal increments jobs_finished_total for terminal statuses.
@@ -205,6 +258,35 @@ func SetBirdSessionMetrics(established int, scrapeOK bool) {
}
}
// SetBirdProtocolStates caches parsed BGP protocol states from the last birdc scrape.
func SetBirdProtocolStates(states map[string]string) {
birdProtocolStatesMu.Lock()
defer birdProtocolStatesMu.Unlock()
if states == nil {
birdProtocolStates = map[string]string{}
} else {
birdProtocolStates = states
}
birdProtocolStatesAt = time.Now()
}
// CachedBirdProtocolStates returns cached protocol states if younger than maxAge.
func CachedBirdProtocolStates(maxAge time.Duration) (map[string]string, bool) {
if maxAge <= 0 {
maxAge = 60 * time.Second
}
birdProtocolStatesMu.RLock()
defer birdProtocolStatesMu.RUnlock()
if birdProtocolStates == nil || time.Since(birdProtocolStatesAt) > maxAge {
return nil, false
}
out := make(map[string]string, len(birdProtocolStates))
for k, v := range birdProtocolStates {
out[k] = v
}
return out, true
}
// MetricsHandler returns the Prometheus scrape handler.
func MetricsHandler() http.Handler {
return promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{})
@@ -231,7 +313,7 @@ func (s *statusRecorder) WriteHeader(code int) {
// StartBirdProtocolsPoller runs birdc "show protocols" on interval when socket is non-empty.
// Горутина завершается при отмене ctx (корректное завершение вместе с процессом API).
func StartBirdProtocolsPoller(ctx context.Context, socket string, birdcPath string, interval time.Duration, showFn func(ctx context.Context, socket, birdcBin string) (string, error), countFn func(output string) int) {
func StartBirdProtocolsPoller(ctx context.Context, socket string, birdcPath string, interval time.Duration, showFn func(ctx context.Context, socket, birdcBin string) (string, error), countFn func(output string) int, parseFn func(output string) map[string]string) {
socket = trimSpace(socket)
if ctx == nil || socket == "" || interval <= 0 || showFn == nil || countFn == nil {
return
@@ -245,6 +327,9 @@ func StartBirdProtocolsPoller(ctx context.Context, socket string, birdcPath stri
return
}
SetBirdSessionMetrics(countFn(out), true)
if parseFn != nil {
SetBirdProtocolStates(parseFn(out))
}
}
go func() {
scrape()
+1
View File
@@ -44,6 +44,7 @@ func resolveASNForEntry(ctx context.Context, st store.Backend, hc *http.Client,
if err != nil {
return nil, "", err
}
asnresolve.PolitePause()
holder, _ := asnresolve.ASHolderName(ctx, hc, asn)
if st != nil {
strs := make([]string, len(pfxs))
+95 -4
View File
@@ -25,9 +25,6 @@ func cachedCDNPrefixRows(st store.Backend, tenantID, moduleID string, priorSnaps
return cached
}
}
if cached := latestCDNRowsBySource(st, tenantID)[sourceKey]; len(cached) > 0 {
return cached
}
}
return nil
}
@@ -45,6 +42,35 @@ func mergeSnapshotDropSource(rows []store.PrefixRow, sourceKey string) []store.P
return out
}
// mergeSnapshotDropCDNSources removes all cdn:* rows (used before batch CDN merge).
func mergeSnapshotDropCDNSources(rows []store.PrefixRow) []store.PrefixRow {
if len(rows) == 0 {
return nil
}
out := make([]store.PrefixRow, 0, len(rows))
for _, row := range rows {
if !strings.HasPrefix(strings.TrimSpace(row.Source), "cdn:") {
out = append(out, row)
}
}
return out
}
// mergeAllCDNSourcesIntoModuleSnapshot replaces all CDN rows in one write (avoids parallel read-modify-write races).
func mergeAllCDNSourcesIntoModuleSnapshot(st store.Backend, tenantID string, mod *store.Module, priorSnapshot []store.PrefixRow, cdnRows []store.PrefixRow) error {
if st == nil || mod == nil {
return nil
}
var base []store.PrefixRow
if len(priorSnapshot) > 0 {
base = mergeSnapshotDropCDNSources(priorSnapshot)
} else if snap, ok, _ := st.GetModulePrefixSnapshot(tenantID, mod.ID); ok && snap != nil {
base = mergeSnapshotDropCDNSources(snap.Prefixes)
}
merged := append(base, cdnRows...)
return persistModuleSnapshot(st, tenantID, mod, merged)
}
func cdnRowsFromParsed(mod *store.Module, src *store.CDNSource, pfxStrings []string) []store.PrefixRow {
var rows []store.PrefixRow
for _, p := range pfxStrings {
@@ -126,7 +152,7 @@ func applyCDNSourceHTTPResult(ctx context.Context, st store.Backend, hc *http.Cl
return nil, fmt.Errorf("cdn url %s: 304 without cached prefixes", u)
}
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
@@ -156,3 +182,68 @@ func applyCDNSourceHTTPResult(ctx context.Context, st store.Backend, hc *http.Cl
}
return rows, nil
}
// fetchCDNSourceRows loads CDN prefixes without persisting the module snapshot (caller merges once).
func fetchCDNSourceRows(ctx context.Context, st store.Backend, hc *http.Client, tenantID, moduleID string, mod *store.Module, src *store.CDNSource, priorSnapshot []store.PrefixRow, now time.Time) ([]store.PrefixRow, error) {
u := strings.TrimSpace(src.URL)
if u == "" {
return nil, nil
}
sourceKey := cdnSourceKey(src.ID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
if etag := strings.TrimSpace(src.Etag); etag != "" {
req.Header.Set("If-None-Match", etag)
}
resp, err := hc.Do(req)
if err != nil {
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
}
if resp.StatusCode == http.StatusNotModified {
if cached := cachedCDNPrefixRows(st, tenantID, moduleID, priorSnapshot, sourceKey); len(cached) > 0 {
_ = resp.Body.Close()
return cached, nil
}
_ = resp.Body.Close()
req2, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
resp, err = hc.Do(req2)
if err != nil {
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
}
if resp.StatusCode == http.StatusNotModified {
_ = resp.Body.Close()
return nil, fmt.Errorf("cdn url %s: 304 without cached prefixes", u)
}
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
return nil, fmt.Errorf("cdn url %s: %s", u, resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return nil, err
}
prefixStrs, err := parseCDNBody(string(body), src)
if err != nil {
return nil, fmt.Errorf("cdn parse %s: %w", u, err)
}
etag := strings.TrimSpace(resp.Header.Get("ETag"))
patch := &store.CDNSourcePatch{}
if etag != "" && etag != strings.TrimSpace(src.Etag) {
e := etag
patch.Etag = &e
}
refreshedAt := now
patch.LastRefreshedAt = &refreshedAt
_, _ = st.UpdateCDNSource(tenantID, moduleID, src.ID, patch)
return cdnRowsFromParsed(mod, src, prefixStrs), nil
}
+22 -7
View File
@@ -97,15 +97,18 @@ func collectASPrefixRows(ctx context.Context, st store.Backend, hc *http.Client,
seenPfx := make(map[string]struct{})
var out []store.PrefixRow
var metaUpdates []store.ASEntryResolveMetaUpdate
now := time.Now().UTC()
for _, r := range results {
if r.err != nil {
return nil, r.err
}
if r.metaID != "" {
if err := st.UpdateASEntryResolveMeta(tenantID, moduleID, r.metaID, r.holder, r.count, now); err != nil {
return nil, fmt.Errorf("as entry meta AS%d: %w", r.asn, err)
}
metaUpdates = append(metaUpdates, store.ASEntryResolveMetaUpdate{
EntryID: r.metaID,
ASNName: r.holder,
PrefixCount: r.count,
})
}
for _, row := range r.rows {
k := row.Prefix
@@ -116,6 +119,11 @@ func collectASPrefixRows(ctx context.Context, st store.Backend, hc *http.Client,
out = append(out, row)
}
}
if len(metaUpdates) > 0 {
if err := st.UpdateASEntryResolveMetaBatch(tenantID, moduleID, metaUpdates, now); err != nil {
return nil, fmt.Errorf("as entry meta batch: %w", err)
}
}
return out, nil
}
@@ -150,12 +158,14 @@ func collectCDNPrefixRows(ctx context.Context, st store.Backend, hc *http.Client
results[idx] = srcResult{rows: cached}
return
}
if cached := latestCDNRowsBySource(st, tenantID)[sourceKey]; len(cached) > 0 {
results[idx] = srcResult{rows: cached}
return
if snap, ok, _ := st.GetModulePrefixSnapshot(tenantID, moduleID); ok && snap != nil {
if cached := prefixRowsForSource(snap.Prefixes, sourceKey); len(cached) > 0 {
results[idx] = srcResult{rows: cached}
return
}
}
}
rows, err := applyCDNSourceHTTPResult(ctx, st, hc, tenantID, moduleID, mod, src, priorSnapshot, now)
rows, err := fetchCDNSourceRows(ctx, st, hc, tenantID, moduleID, mod, src, priorSnapshot, now)
if err != nil {
results[idx] = srcResult{err: err}
return
@@ -172,6 +182,11 @@ func collectCDNPrefixRows(ctx context.Context, st store.Backend, hc *http.Client
}
out = append(out, r.rows...)
}
if len(valid) > 0 {
if err := mergeAllCDNSourcesIntoModuleSnapshot(st, tenantID, mod, priorSnapshot, out); err != nil {
return nil, err
}
}
return out, nil
}
@@ -46,4 +46,3 @@ func TestBuildPreviewFragments_SamePrefixDifferentCommunity(t *testing.T) {
t.Fatalf("expected deterministic static preview text, got first:\n%s\nsecond:\n%s", staticV4, staticV4Second)
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ func ParseCIDRLines(body string) []netip.Prefix {
return out
}
// ExtractCIDRs parses CIDRs from either plaintext lines or JSON payload.
// ExtractCIDRs parses CIDR prefixes from plaintext lines or a JSON payload (see sourceKind and prefixPath).
// For sourceKind="json", prefixPath supports dotted traversal, with [] for arrays:
// e.g. "prefixes[]", "data.items[].cidr".
func ExtractCIDRs(body, sourceKind, prefixPath string) ([]netip.Prefix, error) {
+28 -38
View File
@@ -36,6 +36,11 @@ const (
revisionDefaultTTL = 30 * 24 * time.Hour
)
// AuxBirdFullExpandedKey returns the preview map key for the expanded BIRD config (generated on demand).
func AuxBirdFullExpandedKey() string {
return auxBirdFullExpanded
}
// MaterializedASPrefixKey returns the revision snapshot key for an AS-only entry (not a CIDR).
func MaterializedASPrefixKey(asn int64) string {
return fmt.Sprintf("as:%d", asn)
@@ -47,10 +52,14 @@ func RefreshModuleIngest(ctx context.Context, st store.Backend, hc *http.Client,
if hc == nil {
hc = http.DefaultClient
}
start := time.Now()
mod, err := st.GetModule(tenantID, moduleID)
if err != nil {
return err
}
defer func() {
observability.RecordPipelineRefresh(mod.Type, time.Since(start))
}()
if !mod.Enabled {
return fmt.Errorf("pipeline: module disabled")
}
@@ -197,33 +206,6 @@ func shouldSkipCDNSourceFetch(src *store.CDNSource, now time.Time) bool {
return now.UTC().Before(nextRefreshAt)
}
func latestCDNRowsBySource(st store.Backend, tenantID string) map[string][]store.PrefixRow {
out := make(map[string][]store.PrefixRow)
if st == nil {
return out
}
revs, _, _ := st.ListRevisions(tenantID, "", "", 1)
if len(revs) == 0 || strings.TrimSpace(revs[0].ID) == "" {
return out
}
revID := strings.TrimSpace(revs[0].ID)
cursor := ""
for {
page, next, more := st.ListRevisionPrefixes(tenantID, revID, cursor, 2000)
for _, row := range page {
if !strings.HasPrefix(strings.TrimSpace(row.Source), "cdn:") {
continue
}
out[row.Source] = append(out[row.Source], row)
}
if !more || strings.TrimSpace(next) == "" {
break
}
cursor = next
}
return out
}
type dohJSONAnswer struct {
Type int `json:"type"`
Data string `json:"data"`
@@ -306,7 +288,7 @@ func resolveDomainWithDOHMessage(ctx context.Context, hc *http.Client, baseURL,
if err != nil {
return nil, err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("doh dns-message status %s: %s", resp.Status, strings.TrimSpace(string(body)))
@@ -378,7 +360,7 @@ func resolveDomainWithDOHJSON(ctx context.Context, hc *http.Client, baseURL, hos
if err != nil {
return nil, err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("doh status %s: %s", resp.Status, strings.TrimSpace(string(body)))
@@ -725,15 +707,15 @@ func dedupeSortedPrefixLines(rows []store.PrefixRow) []prefixHashLine {
}
func writePrefixLinesHash(h interface{ Write([]byte) (int, error) }, tenantID string, lines []prefixHashLine) {
h.Write([]byte(strings.TrimSpace(tenantID)))
h.Write([]byte{0})
_, _ = h.Write([]byte(strings.TrimSpace(tenantID)))
_, _ = h.Write([]byte{0})
for _, l := range lines {
h.Write([]byte(l.p))
h.Write([]byte{1})
h.Write([]byte(l.c))
h.Write([]byte{1})
h.Write([]byte(l.s))
h.Write([]byte{0})
_, _ = h.Write([]byte(l.p))
_, _ = h.Write([]byte{1})
_, _ = h.Write([]byte(l.c))
_, _ = h.Write([]byte{1})
_, _ = h.Write([]byte(l.s))
_, _ = h.Write([]byte{0})
}
}
@@ -836,10 +818,18 @@ func buildPreviewFragments(st store.Backend, tenantID, moduleID, revisionID stri
px6: birdfmt.JoinFragments(birdfmt.ManagedBanner(revisionID), staticV6),
pPeers: peersBody,
}
out[auxBirdFullExpanded] = buildExpandedBirdText(main, out)
return out, nil
}
// BuildExpandedBirdPreview concatenates bird.conf and deployable includes for UI preview (not persisted in revision).
func BuildExpandedBirdPreview(frags map[string]string) string {
if frags == nil {
return ""
}
main := frags["bird.conf"]
return buildExpandedBirdText(main, frags)
}
func renderStaticProtocolsByCommunity(groups []staticCommunityRoutes) (string, string) {
var b4 strings.Builder
var b6 strings.Builder
+70
View File
@@ -0,0 +1,70 @@
package repository
import (
"context"
"os"
"testing"
"evobgp/internal/db"
"evobgp/internal/store"
)
func TestPostgresImplementsBackend(t *testing.T) {
var _ store.Backend = (*Postgres)(nil)
}
func TestPostgresPingIntegration(t *testing.T) {
dsn := os.Getenv("EVOBGP_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("EVOBGP_TEST_DATABASE_URL not set")
}
ctx := context.Background()
pool, err := db.OpenPostgresPool(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
pg, err := NewPostgres(ctx, pool, false)
if err != nil {
t.Fatal(err)
}
if err := pg.Ping(ctx); err != nil {
t.Fatalf("ping: %v", err)
}
}
func TestPostgresModuleCRUDIntegration(t *testing.T) {
dsn := os.Getenv("EVOBGP_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("EVOBGP_TEST_DATABASE_URL not set")
}
ctx := context.Background()
pool, err := db.OpenPostgresPool(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
pg, err := NewPostgres(ctx, pool, false)
if err != nil {
t.Fatal(err)
}
tenant := "01TESTTENANT00000000000001"
mod, err := pg.CreateModule(tenant, &store.Module{
Name: "audit-test",
Type: "IP_RANGES",
Enabled: true,
})
if err != nil {
t.Fatal(err)
}
got, err := pg.GetModule(tenant, mod.ID)
if err != nil {
t.Fatal(err)
}
if got.Name != "audit-test" {
t.Fatalf("name: got %q", got.Name)
}
if err := pg.SoftDeleteModule(tenant, mod.ID); err != nil {
t.Fatal(err)
}
}
+50
View File
@@ -0,0 +1,50 @@
package repository
import (
"context"
"encoding/json"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// JobAuditWriter persists async job lifecycle rows to job_audit (optional cross-process queue foundation).
type JobAuditWriter struct {
pool *pgxpool.Pool
}
func NewJobAuditWriter(pool *pgxpool.Pool) *JobAuditWriter {
if pool == nil {
return nil
}
return &JobAuditWriter{pool: pool}
}
// UpsertRunning inserts or updates a running job row (best-effort).
func (w *JobAuditWriter) UpsertRunning(ctx context.Context, tenantID, jobID, kind string, idempotencyKey *string, meta map[string]any) {
if w == nil || w.pool == nil {
return
}
metaJSON, _ := json.Marshal(meta)
var idem any
if idempotencyKey != nil && *idempotencyKey != "" {
idem = *idempotencyKey
}
_, _ = w.pool.Exec(ctx, `
INSERT INTO job_audit (id, tenant_id, kind, status, idempotency_key, meta_json, created_at, started_at)
VALUES ($1::uuid, $2::uuid, $3, 'running', $4, $5::jsonb, now(), now())
ON CONFLICT (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL
DO UPDATE SET status='running', started_at=now(), meta_json=EXCLUDED.meta_json`,
jobID, tenantID, kind, idem, metaJSON)
}
// MarkTerminal updates job_audit terminal state (best-effort).
func (w *JobAuditWriter) MarkTerminal(ctx context.Context, tenantID, jobID, status string, errMsg *string, finishedAt time.Time) {
if w == nil || w.pool == nil {
return
}
_, _ = w.pool.Exec(ctx, `
UPDATE job_audit SET status=$3, error_message=$4, finished_at=$5
WHERE id=$1::uuid AND tenant_id=$2::uuid`,
jobID, tenantID, status, errMsg, finishedAt.UTC())
}
+133 -28
View File
@@ -28,7 +28,7 @@ func agentDebugNDJSON3214(hypothesisID, location, message string, data map[strin
if err != nil {
return
}
defer f.Close()
defer func() { _ = f.Close() }()
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
payload := map[string]any{
@@ -71,6 +71,11 @@ func (p *Postgres) DemoIDs() (tenant, moduleCDN, moduleIP, revision, speaker str
return p.demoTenant, p.demoCDN, p.demoIP, p.demoRev, p.demoSpk
}
// Ping checks PostgreSQL connectivity.
func (p *Postgres) Ping(ctx context.Context) error {
return p.pool.Ping(ctx)
}
func (p *Postgres) MaterializedPrefixStats() (max int, sum int) {
ctx := context.Background()
// Агрегация в БД — не тащим все строки config_revision в память.
@@ -121,6 +126,7 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
}
defer rows.Close()
var out []*store.Module
moduleByID := make(map[string]*store.Module)
for rows.Next() {
var m store.Module
m.TenantID = tenantID
@@ -147,14 +153,84 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
t := last.UTC()
m.LastRefreshedAt = &t
}
if err := p.fillModuleDohFields(ctx, &m); err != nil {
continue
}
out = append(out, &m)
moduleByID[m.ID] = &m
}
if err := p.batchFillModuleDohFields(ctx, moduleByID); err != nil {
return nil
}
return out
}
func (p *Postgres) ListModulesPage(tenantID, cursor string, limit int) ([]*store.Module, string, bool) {
if limit <= 0 {
limit = 50
}
off := 0
if cursor != "" {
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
off = n
}
}
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
FROM module WHERE tenant_id = $1 AND deleted_at IS NULL
ORDER BY priority, name
LIMIT $2 OFFSET $3`, tenantID, limit+1, off)
if err != nil {
return nil, "", false
}
defer rows.Close()
var out []*store.Module
moduleByID := make(map[string]*store.Module)
for rows.Next() {
var m store.Module
m.TenantID = tenantID
var doh, dc, cron *string
var refresh *int32
var last *time.Time
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last); err != nil {
continue
}
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
if refresh != nil {
m.RefreshIntervalSec = int(*refresh)
}
if cron != nil {
m.CronExpr = *cron
}
if doh != nil && *doh != "" {
m.DohProfileID = doh
}
if dc != nil && *dc != "" {
m.DefaultCommunityID = dc
}
if last != nil {
t := last.UTC()
m.LastRefreshedAt = &t
}
out = append(out, &m)
moduleByID[m.ID] = &m
}
if err := p.batchFillModuleDohFields(ctx, moduleByID); err != nil {
return nil, "", false
}
more := len(out) > limit
if more {
out = out[:limit]
}
next := ""
if more {
next = fmt.Sprintf("%d", off+limit)
}
if len(out) == 0 {
return nil, "", false
}
return out, next, more
}
func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
ctx := context.Background()
var m store.Module
@@ -680,7 +756,13 @@ func (p *Postgres) ListRevisionPrefixes(tenantID, revisionID string, cursor stri
}
}
ctx := context.Background()
if _, err := p.GetRevision(tenantID, revisionID); err != nil {
var one int
if err := p.pool.QueryRow(ctx, `
SELECT 1 FROM config_revision WHERE id = $1::uuid AND tenant_id = $2::uuid`,
revisionID, tenantID).Scan(&one); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", false
}
return nil, "", false
}
rows, err := p.pool.Query(ctx, `
@@ -749,6 +831,8 @@ func (p *Postgres) CreateRollbackRevision(tenantID, sourceRevisionID string) (st
return newID, nil
}
const maxRevisionDiffRows = 5000
func (p *Postgres) RevisionDiff(tenantID, aID, bID string) (map[string]any, error) {
if _, err := p.GetRevision(tenantID, aID); err != nil {
return nil, err
@@ -773,7 +857,7 @@ func (p *Postgres) RevisionDiff(tenantID, aID, bID string) (map[string]any, erro
SELECT prefix FROM revision_materialized_prefix WHERE revision_id=$1::uuid
EXCEPT
SELECT prefix FROM revision_materialized_prefix WHERE revision_id=$2::uuid
) s ORDER BY 1`, bID, aID)
) s ORDER BY 1 LIMIT $3`, bID, aID, maxRevisionDiffRows+1)
if err != nil {
return nil, err
}
@@ -785,13 +869,18 @@ func (p *Postgres) RevisionDiff(tenantID, aID, bID string) (map[string]any, erro
continue
}
added = append(added, s)
if len(added) > maxRevisionDiffRows {
added = added[:maxRevisionDiffRows]
break
}
}
addedTruncated := len(added) >= maxRevisionDiffRows
rowsRem, err := p.pool.Query(ctx, `
SELECT prefix::text FROM (
SELECT prefix FROM revision_materialized_prefix WHERE revision_id=$1::uuid
EXCEPT
SELECT prefix FROM revision_materialized_prefix WHERE revision_id=$2::uuid
) s ORDER BY 1`, aID, bID)
) s ORDER BY 1 LIMIT $3`, aID, bID, maxRevisionDiffRows+1)
if err != nil {
return nil, err
}
@@ -803,40 +892,56 @@ func (p *Postgres) RevisionDiff(tenantID, aID, bID string) (map[string]any, erro
continue
}
removed = append(removed, s)
if len(removed) > maxRevisionDiffRows {
removed = removed[:maxRevisionDiffRows]
break
}
}
return map[string]any{
"revision_a": aID,
"revision_b": bID,
"prefixes": map[string]any{
"added": added, "removed": removed, "unchanged_count": unchanged,
"truncated": addedTruncated || len(removed) >= maxRevisionDiffRows,
},
}, nil
}
func (p *Postgres) PruneRevisionsBefore(tenantID string, cutoff time.Time) (int, error) {
ctx := context.Background()
cmd, err := p.pool.Exec(ctx, `
DELETE FROM config_revision AS cr
WHERE cr.tenant_id = $1
AND cr.created_at < $2
AND cr.id <> (
SELECT id
FROM config_revision
WHERE tenant_id = $1
ORDER BY created_at DESC
LIMIT 1
)
AND NOT EXISTS (
SELECT 1
FROM bgp_speaker AS sp
WHERE sp.tenant_id = $1
AND (sp.last_applied_revision_id = cr.id OR sp.published_revision_id = cr.id)
)`,
tenantID, cutoff.UTC())
if err != nil {
return 0, err
total := 0
const batchSize = 50
for {
cmd, err := p.pool.Exec(ctx, `
DELETE FROM config_revision AS cr
WHERE cr.id IN (
SELECT id FROM config_revision
WHERE tenant_id = $1
AND created_at < $2
AND id <> (
SELECT id FROM config_revision
WHERE tenant_id = $1
ORDER BY created_at DESC
LIMIT 1
)
AND NOT EXISTS (
SELECT 1 FROM bgp_speaker AS sp
WHERE sp.tenant_id = $1
AND (sp.last_applied_revision_id = config_revision.id OR sp.published_revision_id = config_revision.id)
)
ORDER BY created_at ASC
LIMIT $3
)`, tenantID, cutoff.UTC(), batchSize)
if err != nil {
return total, err
}
n := int(cmd.RowsAffected())
total += n
if n < batchSize {
break
}
}
return int(cmd.RowsAffected()), nil
return total, nil
}
func (p *Postgres) SetLastAppliedRevision(tenantID, speakerID, revisionID string) error {
+203
View File
@@ -0,0 +1,203 @@
package repository
import (
"context"
"errors"
"strings"
"time"
"evobgp/internal/authkey"
"evobgp/internal/store"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func (p *Postgres) ListAPIKeys(tenantID string) ([]*store.APIKey, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id::text, name, role, token_prefix, created_at, updated_at, expires_at, revoked_at, last_used_at
FROM api_key WHERE tenant_id=$1 ORDER BY created_at DESC`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*store.APIKey
for rows.Next() {
k, err := scanAPIKeyRow(rows.Scan, tenantID)
if err != nil {
return nil, err
}
out = append(out, k)
}
return out, rows.Err()
}
func (p *Postgres) GetAPIKey(tenantID, id string) (*store.APIKey, error) {
ctx := context.Background()
row := p.pool.QueryRow(ctx, `
SELECT id::text, name, role, token_prefix, created_at, updated_at, expires_at, revoked_at, last_used_at
FROM api_key WHERE id=$1 AND tenant_id=$2`, id, tenantID)
k, err := scanAPIKeyRow(row.Scan, tenantID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, store.ErrNotFound
}
return nil, err
}
return k, nil
}
func (p *Postgres) CreateAPIKey(tenantID string, in *store.APIKeyCreate) (*store.APIKeyWithSecret, error) {
if in == nil || strings.TrimSpace(in.Name) == "" || !store.ValidAPIKeyRole(in.Role) {
return nil, store.ErrInvalidInput
}
tok, err := authkey.GenerateToken()
if err != nil {
return nil, err
}
id := uuid.NewString()
hash := authkey.HashToken(tok)
prefix := authkey.Prefix(tok)
role := strings.ToLower(strings.TrimSpace(in.Role))
ctx := context.Background()
_, err = p.pool.Exec(ctx, `
INSERT INTO api_key (id, tenant_id, name, role, token_prefix, token_hash, expires_at)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
id, tenantID, strings.TrimSpace(in.Name), role, prefix, hash, in.ExpiresAt)
if err != nil {
return nil, err
}
k, err := p.GetAPIKey(tenantID, id)
if err != nil {
return nil, err
}
return &store.APIKeyWithSecret{APIKey: *k, Token: tok}, nil
}
func (p *Postgres) UpdateAPIKey(tenantID, id string, patch *store.APIKeyPatch) (*store.APIKey, error) {
cur, err := p.GetAPIKey(tenantID, id)
if err != nil {
return nil, err
}
if cur.RevokedAt != nil {
return nil, store.ErrInvalidInput
}
if patch == nil {
return nil, store.ErrInvalidInput
}
if patch.Name != nil {
n := strings.TrimSpace(*patch.Name)
if n == "" {
return nil, store.ErrInvalidInput
}
cur.Name = n
}
if patch.Role != nil {
if !store.ValidAPIKeyRole(*patch.Role) {
return nil, store.ErrInvalidInput
}
cur.Role = strings.ToLower(strings.TrimSpace(*patch.Role))
}
if patch.ClearExpiresAt {
cur.ExpiresAt = nil
} else if patch.ExpiresAt != nil {
cur.ExpiresAt = patch.ExpiresAt
}
ctx := context.Background()
_, err = p.pool.Exec(ctx, `
UPDATE api_key SET name=$3, role=$4, expires_at=$5, updated_at=now()
WHERE id=$1 AND tenant_id=$2 AND revoked_at IS NULL`,
id, tenantID, cur.Name, cur.Role, cur.ExpiresAt)
if err != nil {
return nil, err
}
return p.GetAPIKey(tenantID, id)
}
func (p *Postgres) RevokeAPIKey(tenantID, id string) error {
ctx := context.Background()
tag, err := p.pool.Exec(ctx, `
UPDATE api_key SET revoked_at=now(), updated_at=now()
WHERE id=$1 AND tenant_id=$2 AND revoked_at IS NULL`, id, tenantID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return store.ErrNotFound
}
return nil
}
func (p *Postgres) RotateAPIKey(tenantID, id string) (*store.APIKeyWithSecret, error) {
cur, err := p.GetAPIKey(tenantID, id)
if err != nil {
return nil, err
}
if cur.RevokedAt != nil {
return nil, store.ErrInvalidInput
}
tok, err := authkey.GenerateToken()
if err != nil {
return nil, err
}
hash := authkey.HashToken(tok)
prefix := authkey.Prefix(tok)
ctx := context.Background()
_, err = p.pool.Exec(ctx, `
UPDATE api_key SET token_hash=$3, token_prefix=$4, updated_at=now()
WHERE id=$1 AND tenant_id=$2 AND revoked_at IS NULL`,
id, tenantID, hash, prefix)
if err != nil {
return nil, err
}
k, err := p.GetAPIKey(tenantID, id)
if err != nil {
return nil, err
}
return &store.APIKeyWithSecret{APIKey: *k, Token: tok}, nil
}
func (p *Postgres) ListActiveAPIKeyHashes() ([]store.APIKeyAuthRow, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id::text, tenant_id::text, role, token_hash
FROM api_key
WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []store.APIKeyAuthRow
for rows.Next() {
var row store.APIKeyAuthRow
var hash []byte
if err := rows.Scan(&row.ID, &row.TenantID, &row.Role, &hash); err != nil {
return nil, err
}
row.TokenHash = append([]byte(nil), hash...)
out = append(out, row)
}
return out, rows.Err()
}
func (p *Postgres) TouchAPIKeyLastUsed(id string) error {
ctx := context.Background()
_, err := p.pool.Exec(ctx, `UPDATE api_key SET last_used_at=now() WHERE id=$1`, id)
return err
}
type scanFn func(dest ...any) error
func scanAPIKeyRow(scan scanFn, tenantID string) (*store.APIKey, error) {
var k store.APIKey
k.TenantID = tenantID
var expires, revoked, lastUsed *time.Time
if err := scan(&k.ID, &k.Name, &k.Role, &k.Prefix, &k.CreatedAt, &k.UpdatedAt, &expires, &revoked, &lastUsed); err != nil {
return nil, err
}
k.ExpiresAt = expires
k.RevokedAt = revoked
k.LastUsedAt = lastUsed
return &k, nil
}
+32
View File
@@ -325,6 +325,38 @@ func (p *Postgres) UpdateASEntryResolveMeta(tenantID, moduleID, entryID string,
return nil
}
func (p *Postgres) UpdateASEntryResolveMetaBatch(tenantID, moduleID string, updates []store.ASEntryResolveMetaUpdate, resolvedAt time.Time) error {
if len(updates) == 0 {
return nil
}
if _, err := p.GetModule(tenantID, moduleID); err != nil {
return err
}
ctx := context.Background()
batch := &pgx.Batch{}
for _, u := range updates {
var nameArg any
sn := strings.TrimSpace(u.ASNName)
if sn == "" {
nameArg = nil
} else {
nameArg = sn
}
batch.Queue(`
UPDATE module_as_entry SET asn_name=$3, prefix_count=$4, asn_resolved_at=$5, updated_at=now()
WHERE id=$1 AND module_id=$2`,
u.EntryID, moduleID, nameArg, u.PrefixCount, resolvedAt.UTC())
}
br := p.pool.SendBatch(ctx, batch)
defer func() { _ = br.Close() }()
for range updates {
if _, err := br.Exec(); err != nil {
return err
}
}
return nil
}
func (p *Postgres) DeleteASEntry(tenantID, moduleID, entryID string) error {
if _, err := p.GetModule(tenantID, moduleID); err != nil {
return err
+22 -9
View File
@@ -10,28 +10,41 @@ func (p *Postgres) fillModuleDohFields(ctx context.Context, m *store.Module) err
if m == nil {
return nil
}
return p.batchFillModuleDohFields(ctx, map[string]*store.Module{m.ID: m})
}
func (p *Postgres) batchFillModuleDohFields(ctx context.Context, modules map[string]*store.Module) error {
if len(modules) == 0 {
return nil
}
ids := make([]string, 0, len(modules))
for id := range modules {
ids = append(ids, id)
}
rows, err := p.pool.Query(ctx, `
SELECT doh_profile_id::text
SELECT module_id::text, doh_profile_id::text
FROM module_doh_profile
WHERE module_id = $1
ORDER BY sort_order, doh_profile_id`, m.ID)
WHERE module_id = ANY($1::uuid[])
ORDER BY module_id, sort_order, doh_profile_id`, ids)
if err != nil {
return err
}
defer rows.Close()
var ids []string
byModule := make(map[string][]string, len(modules))
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
var moduleID, profileID string
if err := rows.Scan(&moduleID, &profileID); err != nil {
return err
}
ids = append(ids, id)
byModule[moduleID] = append(byModule[moduleID], profileID)
}
if err := rows.Err(); err != nil {
return err
}
m.DohProfileIDs = store.NormalizeDohProfileIDList(ids)
m.SyncLegacyDohProfileID()
for id, m := range modules {
m.DohProfileIDs = store.NormalizeDohProfileIDList(byModule[id])
m.SyncLegacyDohProfileID()
}
return nil
}
+1 -1
View File
@@ -121,7 +121,7 @@ func postTenantRefresh(ctx context.Context, deps *Deps, moduleIDs []string, idem
if err != nil {
return err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusAccepted {
return nil
}
+8
View File
@@ -0,0 +1,8 @@
package store
// ASEntryResolveMetaUpdate is one row for batch AS resolve metadata writes.
type ASEntryResolveMetaUpdate struct {
EntryID string
ASNName string
PrefixCount int64
}
+73 -1
View File
@@ -1,6 +1,10 @@
package store
import "time"
import (
"context"
"strings"
"time"
)
// Backend is the persistence abstraction for the control plane (memory, PostgreSQL, SQLite).
type Backend interface {
@@ -15,6 +19,8 @@ type Backend interface {
// ListModules returns all modules for a tenant (control plane may paginate in httpapi).
ListModules(tenantID string) []*Module
// ListModulesPage returns one page of modules (limit capped by caller).
ListModulesPage(tenantID, cursor string, limit int) ([]*Module, string, bool)
GetModule(tenantID, moduleID string) (*Module, error)
CreateModule(tenantID string, in *Module) (*Module, error)
UpdateModule(tenantID, moduleID string, patch *ModulePatch) (*Module, error)
@@ -31,6 +37,7 @@ type Backend interface {
DeleteASEntry(tenantID, moduleID, entryID string) error
// UpdateASEntryResolveMeta записывает имя AS, число объявленных префиксов и время успешного резолва (pipeline).
UpdateASEntryResolveMeta(tenantID, moduleID, entryID string, asnName string, prefixCount int64, resolvedAt time.Time) error
UpdateASEntryResolveMetaBatch(tenantID, moduleID string, updates []ASEntryResolveMetaUpdate, resolvedAt time.Time) error
ListDomainEntries(tenantID, moduleID string) ([]*DomainEntry, error)
CreateDomainEntry(tenantID, moduleID string, in *DomainEntry) (*DomainEntry, error)
@@ -82,6 +89,15 @@ type Backend interface {
ListGlobalSettings(tenantID string) (map[string]any, error)
PatchGlobalSettings(tenantID string, patch map[string]any) error
ListAPIKeys(tenantID string) ([]*APIKey, error)
GetAPIKey(tenantID, id string) (*APIKey, error)
CreateAPIKey(tenantID string, in *APIKeyCreate) (*APIKeyWithSecret, error)
UpdateAPIKey(tenantID, id string, patch *APIKeyPatch) (*APIKey, error)
RevokeAPIKey(tenantID, id string) error
RotateAPIKey(tenantID, id string) (*APIKeyWithSecret, error)
ListActiveAPIKeyHashes() ([]APIKeyAuthRow, error)
TouchAPIKeyLastUsed(id string) error
// Module prefix snapshots cache last successful collect per module (pipeline ingest/render).
GetModulePrefixSnapshot(tenantID, moduleID string) (*ModulePrefixSnapshot, bool, error)
SetModulePrefixSnapshot(tenantID, moduleID, inputHash string, prefixes []PrefixRow) error
@@ -90,6 +106,9 @@ type Backend interface {
// ASNPrefixCache stores RIPEstat announced-prefixes per ASN (global TTL cache).
GetASNPrefixCache(asn int64) (*ASNPrefixCacheEntry, bool, error)
SetASNPrefixCache(asn int64, holder string, prefixes []string) error
// Ping verifies backend connectivity (no-op for in-memory).
Ping(ctx context.Context) error
}
// ASNPrefixCacheEntry is a cached RIPEstat response for one ASN.
@@ -218,6 +237,59 @@ type CommunityPatch struct {
ValueJSON *string `json:"value_json,omitempty"`
}
// APIKey is tenant-scoped API key metadata (secret never stored in plaintext).
type APIKey struct {
ID string `json:"id"`
TenantID string `json:"tenant_id,omitempty"`
Name string `json:"name"`
Role string `json:"role"`
Prefix string `json:"prefix"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
}
// APIKeyCreate is input for issuing a new key.
type APIKeyCreate struct {
Name string `json:"name"`
Role string `json:"role"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// APIKeyPatch is a partial update (role change affects auth after resolver reload).
type APIKeyPatch struct {
Name *string `json:"name,omitempty"`
Role *string `json:"role,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
ClearExpiresAt bool `json:"-"`
}
// APIKeyWithSecret is returned only on create/rotate.
type APIKeyWithSecret struct {
APIKey
Token string `json:"token"`
}
// APIKeyAuthRow is used to build the in-process auth index.
type APIKeyAuthRow struct {
ID string
TenantID string
Role string
TokenHash []byte
}
// ValidAPIKeyRole reports whether role is allowed for API keys.
func ValidAPIKeyRole(role string) bool {
switch strings.ToLower(strings.TrimSpace(role)) {
case "viewer", "editor", "operator", "node":
return true
default:
return false
}
}
type PeerPatch struct {
Neighbor *string `json:"neighbor,omitempty"`
RemoteASN *int64 `json:"remote_asn,omitempty"`
+26 -7
View File
@@ -1,6 +1,7 @@
package store
import (
"context"
"errors"
"fmt"
"sort"
@@ -32,16 +33,17 @@ type Memory struct {
peers map[string]*BGPPeer
dohProfiles map[string]*DohProfile
communities map[string]*Community
cdnSources map[string]*CDNSource
asEntries map[string]*ASEntry
domainEnt map[string]*DomainEntry
ipRanges map[string]*IPRangeEntry
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
dohProfiles map[string]*DohProfile
communities map[string]*Community
cdnSources map[string]*CDNSource
asEntries map[string]*ASEntry
domainEnt map[string]*DomainEntry
ipRanges map[string]*IPRangeEntry
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
revPrefixes map[string][]PrefixRow
moduleSnapshots map[string]*moduleSnapshotRec
asnPrefixCache map[int64]*ASNPrefixCacheEntry
apiKeys map[string]*apiKeyRec
// DemoIDs valid after SeedDemo()
demoTenantID string
@@ -56,6 +58,11 @@ type publishedInfo struct {
PublishedAt time.Time
}
type apiKeyRec struct {
APIKey
TokenHash []byte
}
type Tenant struct {
ID string
Name string
@@ -132,6 +139,7 @@ func NewMemory() *Memory {
revPrefixes: make(map[string][]PrefixRow),
moduleSnapshots: make(map[string]*moduleSnapshotRec),
asnPrefixCache: make(map[int64]*ASNPrefixCacheEntry),
apiKeys: make(map[string]*apiKeyRec),
}
}
@@ -291,6 +299,12 @@ func (m *Memory) DemoIDs() (tenant, moduleCDN, moduleIP, revision, speaker strin
return m.demoTenantID, m.demoModuleCDN, m.demoModuleIP, m.demoRevisionID, m.demoSpeakerID
}
// Ping is a no-op for the in-memory backend.
func (m *Memory) Ping(ctx context.Context) error {
_ = ctx
return nil
}
// ListTenantIDs returns tenant ids sorted lexicographically.
func (m *Memory) ListTenantIDs() ([]string, error) {
m.mu.RLock()
@@ -375,6 +389,11 @@ func (m *Memory) ListModules(tenantID string) []*Module {
return out
}
func (m *Memory) ListModulesPage(tenantID, cursor string, limit int) ([]*Module, string, bool) {
all := m.ListModules(tenantID)
return PaginateOffset(all, cursor, limit)
}
// ListPeers returns BGP peers for a tenant (sorted by name).
func (m *Memory) ListPeers(tenantID string) []*BGPPeer {
m.mu.RLock()
+184
View File
@@ -0,0 +1,184 @@
package store
import (
"strings"
"time"
"evobgp/internal/authkey"
"github.com/google/uuid"
)
func (m *Memory) ListAPIKeys(tenantID string) ([]*APIKey, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var out []*APIKey
for _, rec := range m.apiKeys {
if rec.TenantID == tenantID {
out = append(out, apiKeyCopy(&rec.APIKey))
}
}
return out, nil
}
func (m *Memory) GetAPIKey(tenantID, id string) (*APIKey, error) {
m.mu.RLock()
defer m.mu.RUnlock()
rec, ok := m.apiKeys[id]
if !ok || rec.TenantID != tenantID {
return nil, ErrNotFound
}
return apiKeyCopy(&rec.APIKey), nil
}
func (m *Memory) CreateAPIKey(tenantID string, in *APIKeyCreate) (*APIKeyWithSecret, error) {
if in == nil || strings.TrimSpace(in.Name) == "" || !ValidAPIKeyRole(in.Role) {
return nil, ErrInvalidInput
}
tok, err := authkey.GenerateToken()
if err != nil {
return nil, err
}
now := time.Now().UTC()
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.tenants[tenantID]; !ok {
return nil, ErrTenantScope
}
id := uuid.NewString()
k := &apiKeyRec{
APIKey: APIKey{
ID: id,
TenantID: tenantID,
Name: strings.TrimSpace(in.Name),
Role: strings.ToLower(strings.TrimSpace(in.Role)),
Prefix: authkey.Prefix(tok),
CreatedAt: now,
UpdatedAt: now,
ExpiresAt: in.ExpiresAt,
},
TokenHash: authkey.HashToken(tok),
}
m.apiKeys[id] = k
return &APIKeyWithSecret{APIKey: *apiKeyCopy(&k.APIKey), Token: tok}, nil
}
func (m *Memory) UpdateAPIKey(tenantID, id string, patch *APIKeyPatch) (*APIKey, error) {
if patch == nil {
return nil, ErrInvalidInput
}
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.apiKeys[id]
if !ok || rec.TenantID != tenantID {
return nil, ErrNotFound
}
if rec.RevokedAt != nil {
return nil, ErrInvalidInput
}
if patch.Name != nil {
n := strings.TrimSpace(*patch.Name)
if n == "" {
return nil, ErrInvalidInput
}
rec.Name = n
}
if patch.Role != nil {
if !ValidAPIKeyRole(*patch.Role) {
return nil, ErrInvalidInput
}
rec.Role = strings.ToLower(strings.TrimSpace(*patch.Role))
}
if patch.ClearExpiresAt {
rec.ExpiresAt = nil
} else if patch.ExpiresAt != nil {
rec.ExpiresAt = patch.ExpiresAt
}
rec.UpdatedAt = time.Now().UTC()
return apiKeyCopy(&rec.APIKey), nil
}
func (m *Memory) RevokeAPIKey(tenantID, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.apiKeys[id]
if !ok || rec.TenantID != tenantID {
return ErrNotFound
}
now := time.Now().UTC()
rec.RevokedAt = &now
rec.UpdatedAt = now
return nil
}
func (m *Memory) RotateAPIKey(tenantID, id string) (*APIKeyWithSecret, error) {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.apiKeys[id]
if !ok || rec.TenantID != tenantID {
return nil, ErrNotFound
}
if rec.RevokedAt != nil {
return nil, ErrInvalidInput
}
tok, err := authkey.GenerateToken()
if err != nil {
return nil, err
}
now := time.Now().UTC()
rec.TokenHash = authkey.HashToken(tok)
rec.Prefix = authkey.Prefix(tok)
rec.UpdatedAt = now
return &APIKeyWithSecret{APIKey: *apiKeyCopy(&rec.APIKey), Token: tok}, nil
}
func (m *Memory) ListActiveAPIKeyHashes() ([]APIKeyAuthRow, error) {
m.mu.RLock()
defer m.mu.RUnlock()
now := time.Now().UTC()
var out []APIKeyAuthRow
for _, rec := range m.apiKeys {
if rec.RevokedAt != nil {
continue
}
if rec.ExpiresAt != nil && !rec.ExpiresAt.After(now) {
continue
}
out = append(out, APIKeyAuthRow{
ID: rec.ID,
TenantID: rec.TenantID,
Role: rec.Role,
TokenHash: append([]byte(nil), rec.TokenHash...),
})
}
return out, nil
}
func (m *Memory) TouchAPIKeyLastUsed(id string) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.apiKeys[id]
if !ok {
return ErrNotFound
}
now := time.Now().UTC()
rec.LastUsedAt = &now
return nil
}
func apiKeyCopy(k *APIKey) *APIKey {
cp := *k
if k.ExpiresAt != nil {
t := *k.ExpiresAt
cp.ExpiresAt = &t
}
if k.RevokedAt != nil {
t := *k.RevokedAt
cp.RevokedAt = &t
}
if k.LastUsedAt != nil {
t := *k.LastUsedAt
cp.LastUsedAt = &t
}
return &cp
}
+9
View File
@@ -301,6 +301,15 @@ func (m *Memory) UpdateASEntryResolveMeta(tenantID, moduleID, entryID string, as
return nil
}
func (m *Memory) UpdateASEntryResolveMetaBatch(tenantID, moduleID string, updates []ASEntryResolveMetaUpdate, resolvedAt time.Time) error {
for _, u := range updates {
if err := m.UpdateASEntryResolveMeta(tenantID, moduleID, u.EntryID, u.ASNName, u.PrefixCount, resolvedAt); err != nil {
return err
}
}
return nil
}
func (m *Memory) DeleteASEntry(tenantID, moduleID, entryID string) error {
m.mu.Lock()
defer m.mu.Unlock()
+3 -3
View File
@@ -19,9 +19,9 @@ func TestEffectivePeerEnabledOnCreate(t *testing.T) {
func TestParsePeerNeighbor(t *testing.T) {
tests := []struct {
in string
want string
wantOK bool
in string
want string
wantOK bool
}{
{"192.168.0.2", "192.168.0.2", true},
{"192.168.0.2/32", "192.168.0.2", true},
@@ -0,0 +1,6 @@
DROP INDEX IF EXISTS idx_rev_mat_prefix_rev_id;
DROP INDEX IF EXISTS idx_config_revision_tenant_module_created;
DROP INDEX IF EXISTS idx_bgp_speaker_published;
DROP INDEX IF EXISTS idx_bgp_speaker_last_applied;
DROP INDEX IF EXISTS idx_module_default_community;
DROP INDEX IF EXISTS idx_module_doh_profile_id;
@@ -0,0 +1,17 @@
CREATE INDEX IF NOT EXISTS idx_module_doh_profile_id
ON module (doh_profile_id) WHERE deleted_at IS NULL AND doh_profile_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_module_default_community
ON module (default_community_id) WHERE deleted_at IS NULL AND default_community_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_bgp_speaker_last_applied
ON bgp_speaker (last_applied_revision_id) WHERE last_applied_revision_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_bgp_speaker_published
ON bgp_speaker (published_revision_id) WHERE published_revision_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_config_revision_tenant_module_created
ON config_revision (tenant_id, module_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_rev_mat_prefix_rev_id
ON revision_materialized_prefix (revision_id, id);
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_api_key_tenant_active;
DROP INDEX IF EXISTS idx_api_key_token_hash;
DROP TABLE IF EXISTS api_key;
+19
View File
@@ -0,0 +1,19 @@
CREATE TABLE api_key (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
name TEXT NOT NULL,
role TEXT NOT NULL,
token_prefix TEXT NOT NULL,
token_hash BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
CONSTRAINT api_key_role_chk CHECK (role IN ('viewer', 'editor', 'operator', 'node')),
CONSTRAINT api_key_name_chk CHECK (length(trim(name)) > 0),
CONSTRAINT api_key_token_hash_len_chk CHECK (octet_length(token_hash) = 32)
);
CREATE UNIQUE INDEX idx_api_key_token_hash ON api_key (token_hash);
CREATE INDEX idx_api_key_tenant_active ON api_key (tenant_id) WHERE revoked_at IS NULL;
@@ -0,0 +1,6 @@
DROP INDEX IF EXISTS idx_rev_mat_prefix_rev_id;
DROP INDEX IF EXISTS idx_config_revision_tenant_module_created;
DROP INDEX IF EXISTS idx_bgp_speaker_published;
DROP INDEX IF EXISTS idx_bgp_speaker_last_applied;
DROP INDEX IF EXISTS idx_module_default_community;
DROP INDEX IF EXISTS idx_module_doh_profile_id;
@@ -0,0 +1,17 @@
CREATE INDEX IF NOT EXISTS idx_module_doh_profile_id
ON module (doh_profile_id) WHERE deleted_at IS NULL AND doh_profile_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_module_default_community
ON module (default_community_id) WHERE deleted_at IS NULL AND default_community_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_bgp_speaker_last_applied
ON bgp_speaker (last_applied_revision_id) WHERE last_applied_revision_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_bgp_speaker_published
ON bgp_speaker (published_revision_id) WHERE published_revision_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_config_revision_tenant_module_created
ON config_revision (tenant_id, module_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_rev_mat_prefix_rev_id
ON revision_materialized_prefix (revision_id, id);
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_api_key_tenant_active;
DROP INDEX IF EXISTS idx_api_key_token_hash;
DROP TABLE IF EXISTS api_key;
+19
View File
@@ -0,0 +1,19 @@
CREATE TABLE api_key (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
name TEXT NOT NULL,
role TEXT NOT NULL,
token_prefix TEXT NOT NULL,
token_hash BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
expires_at TEXT,
revoked_at TEXT,
last_used_at TEXT,
CHECK (role IN ('viewer', 'editor', 'operator', 'node')),
CHECK (length(trim(name)) > 0),
CHECK (length(token_hash) = 32)
);
CREATE UNIQUE INDEX idx_api_key_token_hash ON api_key (token_hash);
CREATE INDEX idx_api_key_tenant_active ON api_key (tenant_id) WHERE revoked_at IS NULL;
+168 -139
View File
@@ -11,7 +11,7 @@
"@markwylde/semantic-release-gitea": "^2.2.0",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/git": "^10.0.1",
"@semantic-release/exec": "^7.0.0",
"@semantic-release/release-notes-generator": "^14.0.3",
"semantic-release": "^25.0.2"
}
@@ -761,40 +761,28 @@
"node": ">=18"
}
},
"node_modules/@semantic-release/git": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/@semantic-release/git/-/git-10.0.1.tgz",
"integrity": "sha512-eWrx5KguUcU2wUPaO6sfvZI0wPafUKAMNC18aXY4EnNcrZL86dEmpNVnC9uMpGZkmZJ9EfCVJBQx4pV4EMGT1w==",
"node_modules/@semantic-release/exec": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@semantic-release/exec/-/exec-7.1.0.tgz",
"integrity": "sha512-4ycZ2atgEUutspPZ2hxO6z8JoQt4+y/kkHvfZ1cZxgl9WKJId1xPj+UadwInj+gMn2Gsv+fLnbrZ4s+6tK2TFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@semantic-release/error": "^3.0.0",
"@semantic-release/error": "^4.0.0",
"aggregate-error": "^3.0.0",
"debug": "^4.0.0",
"dir-glob": "^3.0.0",
"execa": "^5.0.0",
"lodash": "^4.17.4",
"micromatch": "^4.0.0",
"p-reduce": "^2.0.0"
"execa": "^9.0.0",
"lodash-es": "^4.17.21",
"parse-json": "^8.0.0"
},
"engines": {
"node": ">=14.17"
"node": ">=20.8.1"
},
"peerDependencies": {
"semantic-release": ">=18.0.0"
"semantic-release": ">=24.1.0"
}
},
"node_modules/@semantic-release/git/node_modules/@semantic-release/error": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-3.0.0.tgz",
"integrity": "sha512-5hiM4Un+tpl4cKw3lV4UgzJj+SmfNIDCLLw0TepzQxz9ZGV5ixnqkzIVF+3tp0ZHgcMKE+VNGHJjEeyFG2dcSw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.17"
}
},
"node_modules/@semantic-release/git/node_modules/aggregate-error": {
"node_modules/@semantic-release/exec/node_modules/aggregate-error": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
"integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
@@ -808,7 +796,7 @@
"node": ">=8"
}
},
"node_modules/@semantic-release/git/node_modules/clean-stack": {
"node_modules/@semantic-release/exec/node_modules/clean-stack": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
"integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
@@ -818,7 +806,61 @@
"node": ">=6"
}
},
"node_modules/@semantic-release/git/node_modules/indent-string": {
"node_modules/@semantic-release/exec/node_modules/execa": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
"integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sindresorhus/merge-streams": "^4.0.0",
"cross-spawn": "^7.0.6",
"figures": "^6.1.0",
"get-stream": "^9.0.0",
"human-signals": "^8.0.1",
"is-plain-obj": "^4.1.0",
"is-stream": "^4.0.1",
"npm-run-path": "^6.0.0",
"pretty-ms": "^9.2.0",
"signal-exit": "^4.1.0",
"strip-final-newline": "^4.0.0",
"yoctocolors": "^2.1.1"
},
"engines": {
"node": "^18.19.0 || >=20.5.0"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/@semantic-release/exec/node_modules/get-stream": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz",
"integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sec-ant/readable-stream": "^0.4.1",
"is-stream": "^4.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@semantic-release/exec/node_modules/human-signals": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz",
"integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@semantic-release/exec/node_modules/indent-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
@@ -828,6 +870,106 @@
"node": ">=8"
}
},
"node_modules/@semantic-release/exec/node_modules/is-stream": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
"integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@semantic-release/exec/node_modules/npm-run-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
"integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^4.0.0",
"unicorn-magic": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@semantic-release/exec/node_modules/parse-json": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
"integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.26.2",
"index-to-position": "^1.1.0",
"type-fest": "^4.39.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@semantic-release/exec/node_modules/path-key": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@semantic-release/exec/node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@semantic-release/exec/node_modules/strip-final-newline": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz",
"integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@semantic-release/exec/node_modules/unicorn-magic": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
"integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@semantic-release/github": {
"version": "12.0.8",
"resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.8.tgz",
@@ -2335,30 +2477,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^6.0.0",
"human-signals": "^2.1.0",
"is-stream": "^2.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^4.0.1",
"onetime": "^5.1.2",
"signal-exit": "^3.0.3",
"strip-final-newline": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/fast-content-type-parse": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz",
@@ -2970,16 +3088,6 @@
"node": ">= 20"
}
},
"node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=10.17.0"
}
},
"node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
@@ -3171,19 +3279,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-text-path": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz",
@@ -3683,16 +3778,6 @@
"node": ">= 0.6"
}
},
"node_modules/mimic-fn": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/mimic-response": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz",
@@ -3961,19 +4046,6 @@
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/npm-run-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/npm/node_modules/@gar/promise-retry": {
"version": "1.0.3",
"dev": true,
@@ -5736,22 +5808,6 @@
"node": ">=0.10.0"
}
},
"node_modules/onetime": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-fn": "^2.1.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-cancelable": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz",
@@ -5852,16 +5908,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-reduce": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-2.1.0.tgz",
"integrity": "sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/p-timeout": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz",
@@ -6850,13 +6896,6 @@
"node": ">=8"
}
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"dev": true,
"license": "ISC"
},
"node_modules/signale": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/signale/-/signale-1.4.0.tgz",
@@ -7121,16 +7160,6 @@
"node": ">=4"
}
},
"node_modules/strip-final-newline": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+1 -1
View File
@@ -7,7 +7,7 @@
"@markwylde/semantic-release-gitea": "^2.2.0",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/git": "^10.0.1",
"@semantic-release/exec": "^7.0.0",
"@semantic-release/release-notes-generator": "^14.0.3",
"semantic-release": "^25.0.2"
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env sh
# DEP-03: postgres and sqlite migration sets must have matching numbered pairs.
set -eu
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
PG="$ROOT/migrations/postgres"
SQL="$ROOT/migrations/sqlite"
list_nums() {
dir="$1"
ls "$dir" 2>/dev/null | sed -n 's/^\([0-9]\{6\}\)_.*\.up\.sql$/\1/p' | sort -u
}
migration_only_in() {
# Prints numbers present in $1 but not in $2 (space-separated lists).
haystack="$2 "
for n in $1; do
case "$haystack" in
*" $n "*) ;;
*) echo "$n" ;;
esac
done
}
pg_nums="$(list_nums "$PG")"
sql_nums="$(list_nums "$SQL")"
if [ "$pg_nums" != "$sql_nums" ]; then
echo "check-migrations-pair: postgres and sqlite migration numbers differ" >&2
only_pg="$(migration_only_in "$pg_nums" "$sql_nums")"
only_sql="$(migration_only_in "$sql_nums" "$pg_nums")"
if [ -n "$only_pg" ]; then
echo "postgres only:" >&2
for n in $only_pg; do
echo " $n" >&2
done
fi
if [ -n "$only_sql" ]; then
echo "sqlite only:" >&2
for n in $only_sql; do
echo " $n" >&2
done
fi
exit 1
fi
for n in $pg_nums; do
pg_up=""
sql_up=""
for f in "$PG"/${n}_*.up.sql; do
if [ -f "$f" ]; then
pg_up="$f"
break
fi
done
for f in "$SQL"/${n}_*.up.sql; do
if [ -f "$f" ]; then
sql_up="$f"
break
fi
done
if [ -z "$pg_up" ] || [ -z "$sql_up" ]; then
echo "check-migrations-pair: missing .up.sql for $n" >&2
exit 1
fi
pg_base="$(basename "$pg_up" .up.sql)"
sql_base="$(basename "$sql_up" .up.sql)"
if [ "$pg_base" != "$sql_base" ]; then
echo "check-migrations-pair: name mismatch for $n: $pg_base vs $sql_base" >&2
exit 1
fi
done
echo "check-migrations-pair: ok ($PG and $SQL)"
+8
View File
@@ -16,6 +16,14 @@ if grep -rE 'writeProblem\(w, http\.StatusBadGateway.*err\.Error\(\)' "$HTTPAPI"
FAIL=1
fi
echo "==> ERR-01: no err.Error() in 4xx writeProblem (store/cdn/csv)"
if grep -rE 'writeProblem\(w, http\.Status(NotFound|UnprocessableEntity|BadRequest).*, err\.Error\(\)' "$HTTPAPI" 2>/dev/null; then
FAIL=1
fi
if grep -rE 'writeStoreErr.*err\.Error|writeProblem.*Unprocessable.*err\.Error' "$HTTPAPI" 2>/dev/null; then
FAIL=1
fi
echo "==> ARCH-01: no SQL/pgx queries in httpapi"
if grep -rE 'pool\.(Query|Exec|QueryRow)|SELECT |INSERT INTO |UPDATE .* SET |DELETE FROM ' "$HTTPAPI" 2>/dev/null; then
FAIL=1
+2 -3
View File
@@ -14,7 +14,8 @@
"svelte-sonner": "^1.1.0",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@internationalized/date": "^3.12.0",
@@ -3137,9 +3138,7 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true,
"license": "MIT",
"optional": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+2 -1
View File
@@ -39,6 +39,7 @@
"svelte-sonner": "^1.1.0",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
}
}
+30
View File
@@ -253,3 +253,33 @@ export type JobsResponse = Page<JobRow>;
// ---- Settings ----
export type AppSettings = Record<string, unknown>;
// ---- Auth / API keys ----
export type AuthSession = {
tenant_id: string;
role: 'viewer' | 'editor' | 'operator' | 'node';
};
export type ApiKeyRole = AuthSession['role'];
export type ApiKey = {
id: string;
name: string;
role: ApiKeyRole;
prefix: string;
created_at: string;
updated_at: string;
expires_at: string | null;
revoked_at: string | null;
last_used_at: string | null;
};
export type ApiKeysResponse = Page<ApiKey>;
export type ApiKeyCreate = {
name: string;
role: ApiKeyRole;
expires_at?: string | null;
};
export type ApiKeyCreated = ApiKey & { token: string };
+49
View File
@@ -0,0 +1,49 @@
/** Ответ GET /v1/version (см. docs/openapi.yaml VersionInfo). */
export type VersionInfo = {
version?: string;
api_version?: string;
git_sha?: string;
build_time?: string;
};
const PLACEHOLDER_SHA = new Set(['', 'unknown']);
function nonEmptyString(value: unknown): string {
if (typeof value === 'string') {
const s = value.trim();
return s;
}
if (value == null) return '';
return String(value).trim();
}
/** Semver или legacy api_version; пустая строка, если полей нет. */
export function resolveVersionString(info: VersionInfo | null | undefined): string {
if (!info) return '';
return nonEmptyString(info.version) || nonEmptyString(info.api_version);
}
/** Короткий git SHA; пусто для placeholder «unknown». */
export function resolveGitSha(info: VersionInfo | null | undefined): string {
if (!info) return '';
const sha = nonEmptyString(info.git_sha);
if (!sha || PLACEHOLDER_SHA.has(sha.toLowerCase())) return '';
return sha;
}
/** Заголовок карточки «Версия» на /monitoring. */
export function formatVersionHeadline(info: VersionInfo | null | undefined): string {
if (!info) return '—';
const ver = resolveVersionString(info);
const sha = resolveGitSha(info);
if (ver && sha) return `${ver} (${sha.slice(0, 12)})`;
if (ver) return ver;
if (sha) return sha.slice(0, 12);
return '—';
}
/** Подпись в сайдбаре (v1.2.3). */
export function formatVersionSidebarLabel(info: VersionInfo | null | undefined): string | null {
const ver = resolveVersionString(info);
return ver ? `v${ver}` : null;
}
@@ -0,0 +1,279 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '$lib/api/types.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/ui/core/card/index.js';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Trash2 from '@lucide/svelte/icons/trash-2';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Copy from '@lucide/svelte/icons/copy';
type Props = {
items: ApiKey[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
onRefresh: () => void | Promise<void>;
};
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
const roleOptions: Array<{ value: ApiKeyRole; label: string }> = [
{ value: 'viewer', label: 'viewer — только чтение' },
{ value: 'editor', label: 'editor — CRUD без apply' },
{ value: 'operator', label: 'operator — полный доступ' },
{ value: 'node', label: 'node — только API ноды' }
];
let dialogOpen = $state(false);
let tokenDialogOpen = $state(false);
let revealedToken = $state('');
let form = $state<ApiKeyCreate>({ name: '', role: 'editor' });
let expiresLocal = $state('');
let saving = $state(false);
const columns = [
{ id: 'name', label: 'Имя', sortable: true, sortValue: (k: ApiKey) => k.name },
{ id: 'role', label: 'Роль', sortable: true, sortValue: (k: ApiKey) => k.role },
{ id: 'prefix', label: 'Префикс', sortable: true, sortValue: (k: ApiKey) => k.prefix },
{
id: 'revoked',
label: 'Статус',
sortable: true,
sortValue: (k: ApiKey) => (k.revoked_at ? 1 : 0)
},
{ id: 'actions', label: '', class: 'w-24' }
] as const;
function openCreate() {
form = { name: '', role: 'editor' };
expiresLocal = '';
dialogOpen = true;
}
function showToken(created: ApiKeyCreated) {
revealedToken = created.token;
tokenDialogOpen = true;
}
async function copyToken() {
try {
await navigator.clipboard.writeText(revealedToken);
notify.success('Скопировано');
} catch {
notify.error('Не удалось скопировать');
}
}
function requestRevoke(k: ApiKey) {
if (k.revoked_at) return;
void confirm({
title: 'Отозвать API-ключ?',
description: `${k.name} (${k.prefix}…)`,
confirmLabel: 'Отозвать',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/api-keys/${k.id}`, 'DELETE', undefined, { idempotent: false });
notify.success('Ключ отозван');
await onRefresh();
}
});
}
function requestRotate(k: ApiKey) {
if (k.revoked_at) return;
void confirm({
title: 'Ротировать ключ?',
description: 'Старый токен перестанет работать сразу.',
confirmLabel: 'Ротировать',
onConfirm: async () => {
try {
const out = await apiMutate<ApiKeyCreated>(
`/v1/api-keys/${k.id}/rotate`,
'POST',
undefined,
{ idempotent: false }
);
notify.success('Ключ обновлён');
showToken(out);
await onRefresh();
} catch (e) {
notifyApiError(e);
}
}
});
}
async function save() {
if (!form.name.trim()) {
notify.error('Укажите имя');
return;
}
saving = true;
try {
const body: ApiKeyCreate = {
name: form.name.trim(),
role: form.role
};
if (expiresLocal.trim()) {
const d = new Date(expiresLocal);
if (Number.isNaN(d.getTime())) {
notify.error('Некорректная дата истечения');
return;
}
body.expires_at = d.toISOString();
}
const created = await apiMutate<ApiKeyCreated>('/v1/api-keys', 'POST', body);
notify.success('Ключ создан');
dialogOpen = false;
showToken(created);
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">API-ключи</CardTitle>
<CardDescription>
Управление ключами tenant. Полный токен показывается только при создании и ротации.
</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" variant="outline" onclick={() => onRefresh()} disabled={loading}>
<RefreshCw class={loading ? 'animate-spin' : ''} />
Обновить
</Button>
<Button size="sm" onclick={openCreate}><Plus />Создать</Button>
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(k) => k.id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет ключей"
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
>
{#snippet cell({ row: k, column })}
{#if column.id === 'name'}
<span class="font-medium">{k.name}</span>
{:else if column.id === 'role'}
<span class="font-mono text-sm">{k.role}</span>
{:else if column.id === 'prefix'}
<span class="font-mono text-xs text-muted-foreground">{k.prefix}</span>
{:else if column.id === 'revoked'}
{#if k.revoked_at}
<span class="text-sm text-destructive">отозван</span>
{:else}
<span class="text-sm text-muted-foreground">активен</span>
{/if}
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button
variant="ghost"
size="icon-sm"
title="Ротировать"
disabled={!!k.revoked_at}
onclick={() => requestRotate(k)}
>
<RefreshCw class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
disabled={!!k.revoked_at}
onclick={() => requestRevoke(k)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Новый API-ключ</DialogTitle>
</DialogHeader>
<div class="flex flex-col gap-4 py-2">
<FormField label="Имя" id="key-name" required>
<AppInput id="key-name" bind:value={form.name} placeholder="CI / оператор UI" />
</FormField>
<FormField label="Роль" id="key-role" required>
<Select
type="single"
value={form.role}
onValueChange={(v) => (form.role = v as ApiKeyRole)}
>
<SelectTrigger id="key-role" class="w-full">
{roleOptions.find((o) => o.value === form.role)?.label ?? form.role}
</SelectTrigger>
<SelectContent>
{#each roleOptions as opt (opt.value)}
<SelectItem value={opt.value} label={opt.label}>{opt.label}</SelectItem>
{/each}
</SelectContent>
</Select>
</FormField>
<FormField label="Истекает (опционально)" id="key-expires">
<AppInput id="key-expires" type="datetime-local" bind:value={expiresLocal} />
</FormField>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Создание…' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog bind:open={tokenDialogOpen}>
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Сохраните токен</DialogTitle>
<DialogDescription
>Он больше не будет показан. Скопируйте в безопасное хранилище.</DialogDescription
>
</DialogHeader>
<div class="rounded-md border bg-muted/40 p-3 font-mono text-xs break-all">{revealedToken}</div>
<DialogFooter>
<Button variant="outline" onclick={copyToken}><Copy />Копировать</Button>
<Button onclick={() => (tokenDialogOpen = false)}>Готово</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,188 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, BgpCommunityCreate } from '$lib/api/types.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '$lib/ui/core/dialog/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
type Props = {
items: BgpCommunity[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
onRefresh: () => void | Promise<void>;
};
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<BgpCommunity | null>(null);
let form = $state<BgpCommunityCreate>({ community: '', title: '' });
let saving = $state(false);
const columns = [
{
id: 'community',
label: 'Код сообщества',
sortable: true,
sortValue: (c: BgpCommunity) => c.community
},
{
id: 'title',
label: 'Название',
sortable: true,
sortValue: (c: BgpCommunity) => c.title ?? ''
},
{ id: 'id', label: 'ID' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function displayName(c: BgpCommunity | null) {
if (!c) return '';
const t = c.title?.trim();
return t || c.community;
}
function openCreate() {
editTarget = null;
form = { community: '', title: '' };
dialogOpen = true;
}
function openEdit(c: BgpCommunity) {
editTarget = c;
form = { community: c.community, title: c.title ?? '' };
dialogOpen = true;
}
function requestDelete(c: BgpCommunity) {
void confirm({
title: `Удалить сообщество «${displayName(c)}»?`,
description: 'Это приведёт к удалению привязки во всех модулях.',
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/communities/${c.id}`, 'DELETE', undefined, { idempotent: false });
notify.success('Удалено');
await onRefresh();
}
});
}
async function save() {
if (!form.community.trim()) {
notify.error('Укажите community');
return;
}
saving = true;
try {
const body = { ...form, title: form.title?.trim() || undefined };
if (editTarget) {
await apiMutate(`/v1/communities/${editTarget.id}`, 'PATCH', body);
notify.success('Запись сообщества обновлена');
} else {
await apiMutate('/v1/communities', 'POST', body);
notify.success('Сообщество создано');
}
dialogOpen = false;
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">Сообщества BGP</CardTitle>
<CardDescription>Используются для тегирования префиксов в AS- и CDN-модулях</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(c) => c.id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет сообществ BGP"
emptyDescription="Создайте первое сообщество для тегирования префиксов."
>
{#snippet cell({ row: c, column })}
{#if column.id === 'community'}
<span class="font-mono text-sm font-medium">{c.community}</span>
{:else if column.id === 'title'}
<span>{c.title?.trim() || '—'}</span>
{:else if column.id === 'id'}
<span class="font-mono text-xs text-muted-foreground">{c.id}</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(c)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(c)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle
>{editTarget ? 'Редактировать сообщество BGP' : 'Новое сообщество BGP'}</DialogTitle
>
</DialogHeader>
<div class="flex flex-col gap-4 py-2">
<FormField label="Код сообщества" id="c-community" required>
<AppInput id="c-community" bind:value={form.community} placeholder="65001:120" />
</FormField>
<FormField label="Название" id="c-title" description="Человекочитаемое имя для списков">
<AppInput id="c-title" bind:value={form.title} placeholder="Название" />
</FormField>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,178 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { DohProfile, DohProfileCreate } from '$lib/api/types.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '$lib/ui/core/dialog/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
type Props = {
items: DohProfile[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
onRefresh: () => void | Promise<void>;
};
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<DohProfile | null>(null);
let form = $state<DohProfileCreate & { timeout_ms?: number | null }>({
url: '',
timeout_ms: null,
vault_secret_ref: null
});
let saving = $state(false);
const columns = [
{ id: 'url', label: 'URL', sortable: true, sortValue: (d: DohProfile) => d.url },
{
id: 'timeout_ms',
label: 'Таймаут (мс)',
sortable: true,
sortValue: (d: DohProfile) => d.timeout_ms ?? 0
},
{ id: 'id', label: 'ID' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function openCreate() {
editTarget = null;
form = { url: '', timeout_ms: null, vault_secret_ref: null };
dialogOpen = true;
}
function openEdit(d: DohProfile) {
editTarget = d;
form = { url: d.url, timeout_ms: d.timeout_ms, vault_secret_ref: d.vault_secret_ref };
dialogOpen = true;
}
function requestDelete(d: DohProfile) {
void confirm({
title: 'Удалить DoH профиль?',
description: d.url,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/doh-profiles/${d.id}`, 'DELETE', undefined, { idempotent: false });
notify.success('Удалено');
await onRefresh();
}
});
}
async function save() {
if (!form.url.trim()) {
notify.error('Укажите URL');
return;
}
saving = true;
try {
if (editTarget) {
await apiMutate(`/v1/doh-profiles/${editTarget.id}`, 'PATCH', form);
notify.success('DoH профиль обновлён');
} else {
await apiMutate('/v1/doh-profiles', 'POST', form);
notify.success('DoH профиль создан');
}
dialogOpen = false;
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">DoH профили</CardTitle>
<CardDescription>DNS-over-HTTPS серверы для резолвинга доменных модулей</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(d) => d.id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет DoH профилей"
emptyDescription="Добавьте DNS-over-HTTPS сервер для доменных модулей."
>
{#snippet cell({ row: d, column })}
{#if column.id === 'url'}
<span class="font-mono text-sm">{d.url}</span>
{:else if column.id === 'timeout_ms'}
<span class="text-muted-foreground">{d.timeout_ms ?? '—'}</span>
{:else if column.id === 'id'}
<span class="font-mono text-xs text-muted-foreground">{d.id}</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(d)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(d)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{editTarget ? 'Редактировать' : 'Новый'} DoH профиль</DialogTitle>
</DialogHeader>
<div class="flex flex-col gap-4 py-2">
<FormField label="URL" id="doh-url" required>
<AppInput id="doh-url" bind:value={form.url} placeholder="https://dns.google/dns-query" />
</FormField>
<FormField label="Таймаут (мс)" id="doh-timeout">
<AppInput id="doh-timeout" type="number" bind:value={form.timeout_ms} placeholder="5000" />
</FormField>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,342 @@
<script lang="ts">
import { apiFetch, apiMutate } from '$lib/api/client.js';
import type { AsEntry, BgpCommunity, ModuleRow } from '$lib/api/types.js';
import { formatDateTime } from '$lib/modules/display.js';
import {
communityLabel,
sanitizeFilenamePart,
supportsCsvIO
} from '$lib/components/modules/module-helpers.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import ModuleAsEntryDialog from '$lib/components/modules/ModuleAsEntryDialog.svelte';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Upload from '@lucide/svelte/icons/upload';
import Download from '@lucide/svelte/icons/download';
type Props = {
moduleId: string;
mod: ModuleRow;
entries: AsEntry[];
communities: BgpCommunity[];
loading?: boolean;
onChanged: () => void | Promise<void>;
};
let { moduleId, mod, entries, communities, loading = false, onChanged }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<AsEntry | null>(null);
let selectedIds = $state(new Set<string>());
let deletingBulk = $state(false);
let csvImporting = $state(false);
let csvExporting = $state(false);
let csvFileInput = $state<HTMLInputElement | null>(null);
const activeSelected = $derived.by(() => {
const allowed = new Set(entries.map((e) => e.id));
return [...selectedIds].filter((id) => allowed.has(id));
});
const selectedCount = $derived(activeSelected.length);
const allSelected = $derived(entries.length > 0 && entries.every((e) => selectedIds.has(e.id)));
const columns = [
{ id: 'select', label: '', class: 'w-10' },
{ id: 'asn', label: 'ASN', sortable: true, sortValue: (e: AsEntry) => e.asn },
{
id: 'name',
label: 'Название AS',
sortable: true,
sortValue: (e: AsEntry) => e.asn_name ?? ''
},
{
id: 'prefixes',
label: 'Префиксов',
sortable: true,
sortValue: (e: AsEntry) => e.prefix_count ?? 0,
class: 'text-right'
},
{
id: 'updated',
label: 'Обновлено',
sortable: true,
sortValue: (e: AsEntry) => e.asn_resolved_at ?? ''
},
{ id: 'community', label: 'Community' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function toggleSelection(id: string) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedIds = next;
}
function toggleAll(checked: boolean) {
selectedIds = checked ? new Set(entries.map((e) => e.id)) : new Set<string>();
}
function openCreate() {
editTarget = null;
dialogOpen = true;
}
function openEdit(entry: AsEntry) {
editTarget = entry;
dialogOpen = true;
}
function requestDelete(entry: AsEntry) {
void confirm({
title: 'Удалить запись?',
description: `ASN: ${entry.asn}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/modules/${moduleId}/as-entries/${entry.id}`, 'DELETE', undefined, {
idempotent: false
});
notify.success('Удалено');
await onChanged();
}
});
}
function requestBulkDelete() {
if (selectedCount === 0) return;
void confirm({
title: 'Удалить выбранные AS-записи?',
description: `Будет удалено: ${selectedCount}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: bulkDelete
});
}
async function bulkDelete() {
if (selectedCount === 0) return;
deletingBulk = true;
let deleted = 0;
try {
for (const id of activeSelected) {
try {
await apiMutate(`/v1/modules/${moduleId}/as-entries/${id}`, 'DELETE', undefined, {
idempotent: false
});
deleted += 1;
} catch (e) {
notifyApiError(e);
}
}
if (deleted > 0) notify.success(`Удалено AS-записей: ${deleted}`);
await onChanged();
} finally {
deletingBulk = false;
}
}
async function readErrorText(res: Response): Promise<string> {
const body = (await res.text()).trim();
return body || `HTTP ${res.status}`;
}
async function exportCsv() {
if (!supportsCsvIO(mod.type) || csvExporting) return;
csvExporting = true;
try {
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'GET',
headers: { Accept: 'text/csv' }
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${sanitizeFilenamePart(mod.name)}-${mod.type.toLowerCase()}-entries.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
notifyApiError(e);
} finally {
csvExporting = false;
}
}
function openImportPicker() {
if (!supportsCsvIO(mod.type) || csvImporting) return;
csvFileInput?.click();
}
async function handleImportChange(event: Event) {
const input = event.currentTarget as HTMLInputElement | null;
const file = input?.files?.[0];
if (!file || csvImporting) return;
csvImporting = true;
try {
const fileText = await file.text();
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: fileText
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const payload = (await res.json()) as { imported?: number };
notify.success(`Импортировано записей: ${payload.imported ?? 0}`);
await onChanged();
} catch (e) {
notifyApiError(e);
} finally {
csvImporting = false;
if (input) input.value = '';
}
}
</script>
<input
class="hidden"
type="file"
accept=".csv,text/csv"
bind:this={csvFileInput}
onchange={handleImportChange}
/>
<Card>
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">AS-записи</CardTitle>
<CardDescription>
Номер AS и community; имя, число префиксов и дата обновляются при успешном refresh
(RIPEstat).
</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
<Button
variant="outline"
size="sm"
onclick={openImportPicker}
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
>
<Upload />
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
</Button>
<Button
variant="outline"
size="sm"
onclick={exportCsv}
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
>
<Download />
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
</Button>
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
{#if selectedCount > 0}
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
<Trash2 />
Удалить ({selectedCount})
</Button>
{/if}
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={entries}
rowKey={(e) => e.id}
{loading}
emptyTitle="Нет AS-записей"
emptyDescription="Добавьте ASN или импортируйте CSV."
>
{#snippet toolbar()}
{#if entries.length > 0}
<div class="flex items-center gap-2">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="Выбрать все AS-записи"
/>
<span class="text-sm text-muted-foreground">Выбрать все</span>
</div>
{/if}
{/snippet}
{#snippet cell({ row: entry, column })}
{#if column.id === 'select'}
<Checkbox
checked={selectedIds.has(entry.id)}
aria-label={`Выбрать AS ${entry.asn}`}
onCheckedChange={() => toggleSelection(entry.id)}
/>
{:else if column.id === 'asn'}
<span class="font-mono">{entry.asn}</span>
{:else if column.id === 'name'}
<span
class="max-w-[14rem] truncate text-sm text-muted-foreground"
title={entry.asn_name ?? ''}
>
{entry.asn_name?.trim() ? entry.asn_name : '—'}
</span>
{:else if column.id === 'prefixes'}
<span class="font-mono text-sm">
{entry.prefix_count != null ? entry.prefix_count : '—'}
</span>
{:else if column.id === 'updated'}
<span class="text-sm whitespace-nowrap text-muted-foreground">
{formatDateTime(entry.asn_resolved_at)}
</span>
{:else if column.id === 'community'}
<span class="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(entry)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(entry)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<ModuleAsEntryDialog
bind:open={dialogOpen}
{moduleId}
edit={editTarget}
{communities}
onSaved={onChanged}
onClose={() => {
editTarget = null;
}}
/>
@@ -0,0 +1,137 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { AsEntry, AsEntryCreate, AsEntryPatch, BgpCommunity } from '$lib/api/types.js';
import {
communityLabel,
communityOptionLabel,
fromNullableSelect,
NONE_OPTION,
nullableSelectValue
} from '$lib/components/modules/module-helpers.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Input } from '$lib/ui/core/input/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
moduleId: string;
edit: AsEntry | null;
communities: BgpCommunity[];
onSaved: () => void | Promise<void>;
onClose: () => void;
};
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
let saving = $state(false);
let form = $state<AsEntryCreate>({ asn: 0, community_id: null });
let initKey = $state('');
function resetForm() {
form = edit
? { asn: edit.asn, community_id: edit.community_id }
: { asn: 0, community_id: null };
}
$effect(() => {
if (!open) {
initKey = '';
return;
}
const nextKey = edit?.id ?? 'new';
if (nextKey !== initKey) {
initKey = nextKey;
resetForm();
}
});
async function save() {
const asn = Number(form.asn);
if (!Number.isFinite(asn) || asn < 1 || asn > 4294967295) {
notify.error('Укажите корректный ASN (14294967295)');
return;
}
saving = true;
try {
const body: AsEntryCreate | AsEntryPatch = { asn, community_id: form.community_id };
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/as-entries/${edit.id}`, 'PATCH', body);
notify.success('Запись обновлена');
} else {
await apiMutate(`/v1/modules/${moduleId}/as-entries`, 'POST', body as AsEntryCreate);
notify.success('Запись добавлена');
}
open = false;
await onSaved();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) onClose();
}
</script>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать запись' : 'Новая AS-запись'}</DialogTitle>
<DialogDescription>
Номер автономной системы и community для политики анонса.
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="as-asn">ASN</Label>
<Input
id="as-asn"
type="number"
placeholder="12345"
bind:value={form.asn}
min={1}
max={4294967295}
/>
</div>
<div class="space-y-1.5">
<Label for="as-comm">Community</Label>
<Select
type="single"
value={nullableSelectValue(form.community_id)}
onValueChange={(v) => {
form.community_id = fromNullableSelect(v);
}}
>
<SelectTrigger id="as-comm" class="w-full">
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,270 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type {
BgpCommunity,
CdnPreviewResponse,
CdnSource,
CdnSourceCreate
} from '$lib/api/types.js';
import {
communityLabel,
communityOptionLabel,
fromNullableSelect,
normalizeCdnSourceKind,
NONE_OPTION,
nullableSelectValue
} from '$lib/components/modules/module-helpers.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Input } from '$lib/ui/core/input/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
moduleId: string;
edit: CdnSource | null;
communities: BgpCommunity[];
onSaved: () => void | Promise<void>;
onClose: () => void;
};
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
let saving = $state(false);
let previewLoading = $state(false);
let previewItems = $state<string[]>([]);
let previewTotal = $state(0);
let previewTruncated = $state(false);
let previewError = $state<string | null>(null);
let previewOk = $state(false);
let form = $state<CdnSourceCreate & { refresh_interval_sec?: number | null }>({
url: '',
source_kind: 'plaintext',
prefix_path: '',
community_id: null
});
let initKey = $state('');
function clearPreview() {
previewLoading = false;
previewItems = [];
previewTotal = 0;
previewTruncated = false;
previewError = null;
previewOk = false;
}
function resetForm() {
clearPreview();
form = edit
? {
url: edit.url,
source_kind: normalizeCdnSourceKind(edit.source_kind),
prefix_path: edit.prefix_path ?? '',
community_id: edit.community_id,
refresh_interval_sec: edit.refresh_interval_sec
}
: { url: '', source_kind: 'plaintext', prefix_path: '', community_id: null };
}
$effect(() => {
if (!open) {
initKey = '';
return;
}
const nextKey = edit?.id ?? 'new';
if (nextKey !== initKey) {
initKey = nextKey;
resetForm();
}
});
async function previewCdn() {
const urlTrim = form.url.trim();
if (!urlTrim) {
notify.error('Укажите URL');
return;
}
previewLoading = true;
previewError = null;
previewOk = false;
try {
const res = await apiMutate<CdnPreviewResponse>(
`/v1/modules/${moduleId}/cdn-sources/preview`,
'POST',
{
url: urlTrim,
source_kind: form.source_kind,
prefix_path: form.prefix_path?.trim() ?? ''
}
);
previewItems = res.items;
previewTotal = res.total;
previewTruncated = res.truncated;
previewOk = true;
} catch (e) {
previewError = e instanceof Error ? e.message : String(e);
previewItems = [];
previewTotal = 0;
previewTruncated = false;
previewOk = false;
} finally {
previewLoading = false;
}
}
async function save() {
const urlTrim = form.url.trim();
if (!urlTrim) {
notify.error('Укажите URL');
return;
}
saving = true;
try {
const body = {
...form,
url: urlTrim,
source_kind: form.source_kind,
prefix_path: form.prefix_path?.trim() ?? ''
};
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${edit.id}`, 'PATCH', body);
notify.success('Источник обновлён');
} else {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources`, 'POST', body);
notify.success('Источник добавлен');
}
clearPreview();
open = false;
await onSaved();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) {
clearPreview();
onClose();
}
}
</script>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="cdn-url">URL</Label>
<Input id="cdn-url" placeholder="https://example.com/list.txt" bind:value={form.url} />
</div>
<div class="space-y-1.5">
<Label for="cdn-kind">Тип источника</Label>
<Select
type="single"
value={form.source_kind}
onValueChange={(v) => {
form.source_kind = v || 'plaintext';
}}
>
<SelectTrigger id="cdn-kind" class="w-full">
{form.source_kind === 'json' ? 'json' : 'plaintext'}
</SelectTrigger>
<SelectContent>
<SelectItem value="plaintext">plaintext</SelectItem>
<SelectItem value="json">json</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label for="cdn-prefix-path">JSON path (prefix_path)</Label>
<Input
id="cdn-prefix-path"
placeholder="напр. prefixes[] или data.items[].cidr"
bind:value={form.prefix_path}
/>
{#if form.source_kind === 'json' && !form.prefix_path?.trim()}
<p class="text-xs text-muted-foreground">
Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов.
</p>
{/if}
</div>
<div class="space-y-1.5">
<Label for="cdn-comm">Community</Label>
<Select
type="single"
value={nullableSelectValue(form.community_id)}
onValueChange={(v) => {
form.community_id = fromNullableSelect(v);
}}
>
<SelectTrigger id="cdn-comm" class="w-full">
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label for="cdn-interval">Интервал обновления (сек)</Label>
<Input
id="cdn-interval"
type="number"
placeholder="3600"
bind:value={form.refresh_interval_sec}
/>
</div>
<div class="flex flex-col gap-2 rounded-lg border border-border p-3">
<div class="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onclick={previewCdn}
disabled={previewLoading}
>
{previewLoading ? 'Загрузка…' : 'Предпросмотр'}
</Button>
{#if previewError}
<span class="text-sm text-destructive">{previewError}</span>
{:else if previewOk}
<span class="text-sm text-muted-foreground">
Всего: {previewTotal}{#if previewTruncated}
<span class="text-amber-600 dark:text-amber-500"> (обрезано)</span>{/if}
</span>
{/if}
</div>
{#if previewItems.length}
<ul class="max-h-48 overflow-y-auto rounded-md border bg-muted/40 p-2 font-mono text-xs">
{#each previewItems as item, i (`${i}-${item}`)}
<li class="py-0.5">{item}</li>
{/each}
</ul>
{/if}
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,237 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, CdnSource } from '$lib/api/types.js';
import { formatDateTime } from '$lib/modules/display.js';
import {
communityLabel,
normalizeCdnSourceKind
} from '$lib/components/modules/module-helpers.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import ModuleCdnSourceDialog from '$lib/components/modules/ModuleCdnSourceDialog.svelte';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
type Props = {
moduleId: string;
sources: CdnSource[];
communities: BgpCommunity[];
loading?: boolean;
onChanged: () => void | Promise<void>;
};
let { moduleId, sources, communities, loading = false, onChanged }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<CdnSource | null>(null);
let selectedIds = $state(new Set<string>());
let deletingBulk = $state(false);
const activeSelected = $derived.by(() => {
const allowed = new Set(sources.map((s) => s.id));
return [...selectedIds].filter((id) => allowed.has(id));
});
const selectedCount = $derived(activeSelected.length);
const allSelected = $derived(sources.length > 0 && sources.every((s) => selectedIds.has(s.id)));
const columns = [
{ id: 'select', label: '', class: 'w-10' },
{ id: 'url', label: 'URL', sortable: true, sortValue: (s: CdnSource) => s.url },
{ id: 'kind', label: 'Тип', sortable: true, sortValue: (s: CdnSource) => s.source_kind },
{ id: 'community', label: 'Community' },
{
id: 'interval',
label: 'Интервал',
sortable: true,
sortValue: (s: CdnSource) => s.refresh_interval_sec ?? 0
},
{
id: 'refreshed',
label: 'Последнее обновление',
sortable: true,
sortValue: (s: CdnSource) => s.last_refreshed_at ?? ''
},
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function toggleSelection(id: string) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedIds = next;
}
function toggleAll(checked: boolean) {
selectedIds = checked ? new Set(sources.map((s) => s.id)) : new Set<string>();
}
function openCreate() {
editTarget = null;
dialogOpen = true;
}
function openEdit(src: CdnSource) {
editTarget = src;
dialogOpen = true;
}
function requestDelete(src: CdnSource) {
void confirm({
title: 'Удалить CDN-источник?',
description: src.url,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${src.id}`, 'DELETE', undefined, {
idempotent: false
});
notify.success('Удалено');
await onChanged();
}
});
}
function requestBulkDelete() {
if (selectedCount === 0) return;
void confirm({
title: 'Удалить выбранные CDN-источники?',
description: `Будет удалено: ${selectedCount}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: bulkDelete
});
}
async function bulkDelete() {
if (selectedCount === 0) return;
deletingBulk = true;
let deleted = 0;
try {
for (const id of activeSelected) {
try {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${id}`, 'DELETE', undefined, {
idempotent: false
});
deleted += 1;
} catch (e) {
notifyApiError(e);
}
}
if (deleted > 0) notify.success(`Удалено CDN-источников: ${deleted}`);
await onChanged();
} finally {
deletingBulk = false;
}
}
</script>
<Card>
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">CDN-источники</CardTitle>
<CardDescription>URL источников для скачивания списков CIDR.</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
{#if selectedCount > 0}
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
<Trash2 />
Удалить ({selectedCount})
</Button>
{/if}
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={sources}
rowKey={(s) => s.id}
{loading}
emptyTitle="Нет CDN-источников"
emptyDescription="Добавьте URL для загрузки списков CIDR."
>
{#snippet toolbar()}
{#if sources.length > 0}
<div class="flex items-center gap-2">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="Выбрать все CDN-источники"
/>
<span class="text-sm text-muted-foreground">Выбрать все</span>
</div>
{/if}
{/snippet}
{#snippet cell({ row: src, column })}
{#if column.id === 'select'}
<Checkbox
checked={selectedIds.has(src.id)}
aria-label="Выбрать CDN-источник"
onCheckedChange={() => toggleSelection(src.id)}
/>
{:else if column.id === 'url'}
<span class="max-w-xs truncate font-mono text-xs" title={src.url}>{src.url}</span>
{:else if column.id === 'kind'}
<div class="flex flex-col gap-0.5">
<Badge variant="outline">{normalizeCdnSourceKind(src.source_kind)}</Badge>
{#if src.prefix_path?.trim()}
<span
class="font-mono text-xs break-all text-muted-foreground"
title={src.prefix_path}>{src.prefix_path}</span
>
{/if}
</div>
{:else if column.id === 'community'}
<span class="text-sm text-muted-foreground">
{communityLabel(src.community_id, communities)}
</span>
{:else if column.id === 'interval'}
<span class="text-sm text-muted-foreground">
{src.refresh_interval_sec != null ? `${src.refresh_interval_sec}с` : '—'}
</span>
{:else if column.id === 'refreshed'}
<span class="text-sm whitespace-nowrap text-muted-foreground">
{formatDateTime(src.last_refreshed_at)}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(src)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(src)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<ModuleCdnSourceDialog
bind:open={dialogOpen}
{moduleId}
edit={editTarget}
{communities}
onSaved={onChanged}
onClose={() => {
editTarget = null;
}}
/>
@@ -0,0 +1,144 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { ModuleCreate } from '$lib/api/types.js';
import { moduleTypeRu } from '$lib/ui-labels.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Input } from '$lib/ui/core/input/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { Switch } from '$lib/ui/core/switch/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
onClose: () => void;
onCreated: () => void | Promise<void>;
};
let { open = $bindable(), onClose, onCreated }: Props = $props();
let saving = $state(false);
let form = $state<ModuleCreate>({
type: 'AS_PREFIXES',
name: '',
enabled: true,
priority: 0
});
const moduleTypes = [
{ value: 'AS_PREFIXES', label: moduleTypeRu('AS_PREFIXES') },
{ value: 'CDN_CIDRS', label: moduleTypeRu('CDN_CIDRS') },
{ value: 'DOMAINS', label: moduleTypeRu('DOMAINS') },
{ value: 'IP_RANGES', label: moduleTypeRu('IP_RANGES') }
] as const;
function resetForm() {
form = { type: 'AS_PREFIXES', name: '', enabled: true, priority: 0 };
}
async function create() {
if (!form.name.trim()) {
notify.error('Укажите название модуля');
return;
}
saving = true;
try {
await apiMutate('/v1/modules', 'POST', form);
notify.success('Модуль создан');
open = false;
resetForm();
await onCreated();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) {
onClose();
resetForm();
}
}
</script>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Новый модуль</DialogTitle>
<DialogDescription>Создание нового модуля префиксов.</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="m-name">Название</Label>
<Input id="m-name" bind:value={form.name} placeholder="my-asn-module" />
</div>
<div class="space-y-1.5">
<Label for="m-type">Тип</Label>
<Select
type="single"
value={form.type}
onValueChange={(v) => {
if (v) form.type = v as typeof form.type;
}}
>
<SelectTrigger id="m-type" class="w-full">
{moduleTypes.find((t) => t.value === form.type)?.label ?? 'Выберите тип'}
</SelectTrigger>
<SelectContent>
{#each moduleTypes as t (t.value)}
<SelectItem value={t.value}>{t.label}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="m-priority">Приоритет</Label>
<Input id="m-priority" type="number" bind:value={form.priority} />
</div>
<div class="space-y-1.5">
<Label for="m-interval">Интервал (сек)</Label>
<Input
id="m-interval"
type="number"
placeholder="3600"
bind:value={form.refresh_interval_sec}
/>
</div>
</div>
<div
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
>
<div class="grid min-w-0 flex-1 gap-1 pr-2">
<Label for="m-enabled" class="leading-snug text-foreground">Включён</Label>
<p class="text-xs leading-snug text-muted-foreground">
Модуль участвует в сборке ревизий, если включён.
</p>
</div>
<Switch
id="m-enabled"
class="shrink-0"
checked={form.enabled !== false}
onCheckedChange={(v) => {
form = { ...form, enabled: v };
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={create} disabled={saving}>{saving ? 'Создание…' : 'Создать'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,57 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { ModuleRow } from '$lib/api/types.js';
import { moduleEnabledRu, moduleEnabledBadgeVariant, moduleTypeRu } from '$lib/ui-labels.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Blocks from '@lucide/svelte/icons/blocks';
type Props = {
mod: ModuleRow;
refreshing: boolean;
onRefresh: () => void;
onEdit: () => void;
onDelete: () => void;
};
let { mod, refreshing, onRefresh, onEdit, onDelete }: Props = $props();
</script>
<div class="flex min-w-0 items-start gap-2">
<Button variant="ghost" size="icon-sm" class="mt-1 shrink-0" href={resolve('/modules')}>
<ArrowLeft class="size-4" />
</Button>
<PageHeader
class="min-w-0 flex-1"
title={mod.name}
description={mod.id}
icon={Blocks}
iconClass="bg-chart-2/15 text-chart-2"
>
{#snippet actions()}
<div class="flex flex-wrap items-center gap-2">
<Badge variant="outline">{moduleTypeRu(mod.type)}</Badge>
<Badge variant={moduleEnabledBadgeVariant(!!mod.enabled)} class="text-xs">
{moduleEnabledRu(!!mod.enabled)}
</Badge>
<Button variant="outline" size="sm" onclick={() => onRefresh()} disabled={refreshing}>
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
<Button variant="outline" size="sm" onclick={() => onEdit()}>
<Pencil />
Редактировать
</Button>
<Button variant="destructive" size="sm" onclick={() => onDelete()}>
<Trash2 />
Удалить
</Button>
</div>
{/snippet}
</PageHeader>
</div>
@@ -0,0 +1,302 @@
<script lang="ts">
import { apiFetch, apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, DomainEntry, ModuleRow } from '$lib/api/types.js';
import {
communityLabel,
sanitizeFilenamePart,
supportsCsvIO
} from '$lib/components/modules/module-helpers.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import ModuleDomainEntryDialog from '$lib/components/modules/ModuleDomainEntryDialog.svelte';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Upload from '@lucide/svelte/icons/upload';
import Download from '@lucide/svelte/icons/download';
type Props = {
moduleId: string;
mod: ModuleRow;
entries: DomainEntry[];
communities: BgpCommunity[];
loading?: boolean;
onChanged: () => void | Promise<void>;
};
let { moduleId, mod, entries, communities, loading = false, onChanged }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<DomainEntry | null>(null);
let selectedIds = $state(new Set<string>());
let deletingBulk = $state(false);
let csvImporting = $state(false);
let csvExporting = $state(false);
let csvFileInput = $state<HTMLInputElement | null>(null);
const activeSelected = $derived.by(() => {
const allowed = new Set(entries.map((e) => e.id));
return [...selectedIds].filter((id) => allowed.has(id));
});
const selectedCount = $derived(activeSelected.length);
const allSelected = $derived(entries.length > 0 && entries.every((e) => selectedIds.has(e.id)));
const columns = [
{ id: 'select', label: '', class: 'w-10' },
{ id: 'fqdn', label: 'FQDN', sortable: true, sortValue: (e: DomainEntry) => e.fqdn },
{ id: 'community', label: 'Community' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function toggleSelection(id: string) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedIds = next;
}
function toggleAll(checked: boolean) {
selectedIds = checked ? new Set(entries.map((e) => e.id)) : new Set<string>();
}
function openCreate() {
editTarget = null;
dialogOpen = true;
}
function openEdit(entry: DomainEntry) {
editTarget = entry;
dialogOpen = true;
}
function requestDelete(entry: DomainEntry) {
void confirm({
title: 'Удалить домен?',
description: entry.fqdn,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${entry.id}`, 'DELETE', undefined, {
idempotent: false
});
notify.success('Удалено');
await onChanged();
}
});
}
function requestBulkDelete() {
if (selectedCount === 0) return;
void confirm({
title: 'Удалить выбранные домены?',
description: `Будет удалено: ${selectedCount}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: bulkDelete
});
}
async function bulkDelete() {
if (selectedCount === 0) return;
deletingBulk = true;
let deleted = 0;
try {
for (const id of activeSelected) {
try {
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${id}`, 'DELETE', undefined, {
idempotent: false
});
deleted += 1;
} catch (e) {
notifyApiError(e);
}
}
if (deleted > 0) notify.success(`Удалено доменов: ${deleted}`);
await onChanged();
} finally {
deletingBulk = false;
}
}
async function readErrorText(res: Response): Promise<string> {
const body = (await res.text()).trim();
return body || `HTTP ${res.status}`;
}
async function exportCsv() {
if (!supportsCsvIO(mod.type) || csvExporting) return;
csvExporting = true;
try {
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'GET',
headers: { Accept: 'text/csv' }
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${sanitizeFilenamePart(mod.name)}-${mod.type.toLowerCase()}-entries.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
notifyApiError(e);
} finally {
csvExporting = false;
}
}
function openImportPicker() {
if (!supportsCsvIO(mod.type) || csvImporting) return;
csvFileInput?.click();
}
async function handleImportChange(event: Event) {
const input = event.currentTarget as HTMLInputElement | null;
const file = input?.files?.[0];
if (!file || csvImporting) return;
csvImporting = true;
try {
const fileText = await file.text();
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: fileText
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const payload = (await res.json()) as { imported?: number };
notify.success(`Импортировано записей: ${payload.imported ?? 0}`);
await onChanged();
} catch (e) {
notifyApiError(e);
} finally {
csvImporting = false;
if (input) input.value = '';
}
}
</script>
<input
class="hidden"
type="file"
accept=".csv,text/csv"
bind:this={csvFileInput}
onchange={handleImportChange}
/>
<Card>
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">Домены</CardTitle>
<CardDescription>FQDN для резолвинга через DoH.</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
<Button
variant="outline"
size="sm"
onclick={openImportPicker}
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
>
<Upload />
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
</Button>
<Button
variant="outline"
size="sm"
onclick={exportCsv}
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
>
<Download />
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
</Button>
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
{#if selectedCount > 0}
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
<Trash2 />
Удалить ({selectedCount})
</Button>
{/if}
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={entries}
rowKey={(e) => e.id}
{loading}
emptyTitle="Нет доменов"
emptyDescription="Добавьте FQDN или импортируйте CSV."
>
{#snippet toolbar()}
{#if entries.length > 0}
<div class="flex items-center gap-2">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="Выбрать все домены"
/>
<span class="text-sm text-muted-foreground">Выбрать все</span>
</div>
{/if}
{/snippet}
{#snippet cell({ row: entry, column })}
{#if column.id === 'select'}
<Checkbox
checked={selectedIds.has(entry.id)}
aria-label={`Выбрать домен ${entry.fqdn}`}
onCheckedChange={() => toggleSelection(entry.id)}
/>
{:else if column.id === 'fqdn'}
<span class="font-mono">{entry.fqdn}</span>
{:else if column.id === 'community'}
<span class="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(entry)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(entry)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<ModuleDomainEntryDialog
bind:open={dialogOpen}
{moduleId}
edit={editTarget}
{communities}
onSaved={onChanged}
onClose={() => {
editTarget = null;
}}
/>
@@ -0,0 +1,120 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, DomainEntry, DomainEntryCreate } from '$lib/api/types.js';
import {
communityLabel,
communityOptionLabel,
fromNullableSelect,
NONE_OPTION,
nullableSelectValue
} from '$lib/components/modules/module-helpers.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Input } from '$lib/ui/core/input/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
moduleId: string;
edit: DomainEntry | null;
communities: BgpCommunity[];
onSaved: () => void | Promise<void>;
onClose: () => void;
};
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
let saving = $state(false);
let form = $state<DomainEntryCreate>({ fqdn: '', community_id: null });
let initKey = $state('');
function resetForm() {
form = edit
? { fqdn: edit.fqdn, community_id: edit.community_id }
: { fqdn: '', community_id: null };
}
$effect(() => {
if (!open) {
initKey = '';
return;
}
const nextKey = edit?.id ?? 'new';
if (nextKey !== initKey) {
initKey = nextKey;
resetForm();
}
});
async function save() {
saving = true;
try {
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${edit.id}`, 'PATCH', form);
notify.success('Домен обновлён');
} else {
await apiMutate(`/v1/modules/${moduleId}/domain-entries`, 'POST', form);
notify.success('Домен добавлен');
}
open = false;
await onSaved();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) onClose();
}
</script>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать домен' : 'Новый домен'}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="dom-fqdn">FQDN</Label>
<Input id="dom-fqdn" placeholder="example.com" bind:value={form.fqdn} />
</div>
<div class="space-y-1.5">
<Label for="dom-comm">Community</Label>
<Select
type="single"
value={nullableSelectValue(form.community_id)}
onValueChange={(v) => {
form.community_id = fromNullableSelect(v);
}}
>
<SelectTrigger id="dom-comm" class="w-full">
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

Some files were not shown because too many files have changed in this diff Show More