Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db75126bea | ||
|
|
f39df7c4bf |
No files matched your search
@@ -30,12 +30,13 @@ func main() {
|
|||||||
}
|
}
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
opts := httpapi.Options{
|
opts := httpapi.Options{
|
||||||
APIKeys: os.Getenv("EVOBGP_API_KEYS"),
|
APIKeys: os.Getenv("EVOBGP_API_KEYS"),
|
||||||
DatabaseURL: cfg.DatabaseURL,
|
DatabaseURL: cfg.DatabaseURL,
|
||||||
InsecureDev: os.Getenv("EVOBGP_DEV_INSECURE") == "1",
|
InsecureDev: os.Getenv("EVOBGP_DEV_INSECURE") == "1",
|
||||||
SeedDemo: os.Getenv("EVOBGP_SEED_DEMO") != "0",
|
SeedDemo: os.Getenv("EVOBGP_SEED_DEMO") != "0",
|
||||||
BundleSeedHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_SEED_HEX")),
|
BundleSeedHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_SEED_HEX")),
|
||||||
CORSAllowedOrigins: strings.TrimSpace(os.Getenv("EVOBGP_CORS_ORIGINS")),
|
CORSAllowedOrigins: strings.TrimSpace(os.Getenv("EVOBGP_CORS_ORIGINS")),
|
||||||
|
RuntimeLogsPolicyTenant: cfg.RuntimeLogsPolicyTenant,
|
||||||
}
|
}
|
||||||
srv, err := httpapi.New(opts)
|
srv, err := httpapi.New(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+5
-1
@@ -110,7 +110,11 @@ EVOBGP_SERVICE=evobgp-all
|
|||||||
EVOBGP_RUNTIME_LOGS_DIR=/opt/evobgp/runtime-logs
|
EVOBGP_RUNTIME_LOGS_DIR=/opt/evobgp/runtime-logs
|
||||||
```
|
```
|
||||||
|
|
||||||
В dev-профиле compose каталог на хосте обычно `./runtime-logs`, в контейнере — mount на `/opt/evobgp/runtime-logs`. Если каталог не задан или роль процесса не `evobgp-all`, эндпоинты `/v1/runtime-logs/*` отвечают **503** (`runtime_logs_unavailable`). Очистка файлов — роль **operator+**; операции пишутся в таблицу `runtime_log_cleanup_audit`.
|
В dev-профиле compose каталог на хосте обычно `./runtime-logs`, в контейнере — mount на `/opt/evobgp/runtime-logs`. Если каталог не задан или роль процесса не `evobgp-all`, FS-эндпоинты (`/files`, `/auto-*`) отвечают **503** (`runtime_logs_unavailable`). `GET /v1/runtime-logs/cleanup-audit` доступен без volume.
|
||||||
|
|
||||||
|
Очистка файлов — роль **operator+**; операции пишутся в `runtime_log_cleanup_audit`. Автоочистка настраивается в tenant settings (`runtime_logs_auto_enabled`, `runtime_logs_max_file_mb`, `runtime_logs_auto_schedule`, `runtime_logs_auto_mode`); scheduler — только в `evobgp-all`. Опционально: `EVOBGP_RUNTIME_LOGS_POLICY_TENANT` — tenant, чьи settings читает scheduler (иначе первый tenant с включённой автоочисткой).
|
||||||
|
|
||||||
|
Retention строк audit: пресет maintenance policy `runtime_log_cleanup_audit` (90d) в Monitoring → PostgreSQL → Политики.
|
||||||
|
|
||||||
## CORS для веб-интерфейса
|
## CORS для веб-интерфейса
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -90,7 +90,7 @@
|
|||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
|
|
||||||
- `GET /v1/settings`, `PATCH /v1/settings` — tenant KV (`global_settings`): BIRD, `revision_retention_minutes`, произвольные ключи. Чтение — viewer+; `PATCH` — operator+.
|
- `GET /v1/settings`, `PATCH /v1/settings` — tenant KV (`global_settings`): BIRD, `revision_retention_minutes`, `runtime_logs_*` (автоочистка FS), произвольные ключи. Чтение — viewer+; `PATCH` — operator+.
|
||||||
|
|
||||||
### RuntimeLogs
|
### RuntimeLogs
|
||||||
|
|
||||||
@@ -101,7 +101,9 @@
|
|||||||
| `GET` | `/v1/runtime-logs/files` | viewer+ | Список `*.log` (имя, размер, mtime) |
|
| `GET` | `/v1/runtime-logs/files` | viewer+ | Список `*.log` (имя, размер, mtime) |
|
||||||
| `GET` | `/v1/runtime-logs/files/{filename}` | viewer+ | Хвост файла (`?lines=`, `?bytes=`, `?grep=`) |
|
| `GET` | `/v1/runtime-logs/files/{filename}` | viewer+ | Хвост файла (`?lines=`, `?bytes=`, `?grep=`) |
|
||||||
| `DELETE` | `/v1/runtime-logs/files/{filename}` | operator+ | Синхронная очистка (`?mode=truncate\|delete`, default truncate); max 512 MiB |
|
| `DELETE` | `/v1/runtime-logs/files/{filename}` | operator+ | Синхронная очистка (`?mode=truncate\|delete`, default truncate); max 512 MiB |
|
||||||
| `GET` | `/v1/runtime-logs/cleanup-audit` | viewer+ | Пагинированный audit очистки (`cursor`, `limit`) |
|
| `GET` | `/v1/runtime-logs/cleanup-audit` | viewer+ | Пагинированный audit очистки (`cursor`, `limit`); **без FS volume** |
|
||||||
|
| `GET` | `/v1/runtime-logs/auto-estimate` | operator+ | Файлы выше порога из tenant settings |
|
||||||
|
| `POST` | `/v1/runtime-logs/auto-run` | operator+ | Немедленный прогон (`?dry_run=true`); audit с `actor_prefix=auto:scheduler` |
|
||||||
|
|
||||||
`{filename}` — только basename, паттерн `^[a-z0-9][a-z0-9_.-]*\.log$`. Очистка пишет строку в таблицу `runtime_log_cleanup_audit` (миграция `000026`).
|
`{filename}` — только basename, паттерн `^[a-z0-9][a-z0-9_.-]*\.log$`. Очистка пишет строку в таблицу `runtime_log_cleanup_audit` (миграция `000026`).
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -112,15 +112,16 @@ EvoBGP управляет генерацией и применением BGP-к
|
|||||||
| Маршрут | Назначение |
|
| Маршрут | Назначение |
|
||||||
|---------|------------|
|
|---------|------------|
|
||||||
| `/settings` | Только браузер: API-токен, тема (localStorage). Tenant KV здесь **не** редактируются. |
|
| `/settings` | Только браузер: API-токен, тема (localStorage). Tenant KV здесь **не** редактируются. |
|
||||||
| `/tenant-settings` | Все tenant-параметры из `/v1/settings`: вкладки **BIRD**, **Ревизии** (`revision_retention_minutes`), **Дополнительно** (custom KV). Пункт nav **«Параметры»**. |
|
| `/tenant-settings` | Все tenant-параметры из `/v1/settings`: вкладки **BIRD**, **Ревизии**, **Файловые логи** (автоочистка FS), **Дополнительно** (custom KV). Пункт nav **«Параметры»**. |
|
||||||
| `/network` → Control plane | Краткая сводка BIRD + ссылка на `/tenant-settings?tab=bird`. |
|
| `/network` → Control plane | Краткая сводка BIRD + ссылка на `/tenant-settings?tab=bird`. |
|
||||||
| `/operations` | Ревизии, diff, jobs; вкладка «Система» перенесена в `/tenant-settings`. |
|
| `/operations` | Ревизии, diff, jobs; вкладка «Система» перенесена в `/tenant-settings`. |
|
||||||
|
|
||||||
### Web UI: файловые runtime-логи
|
### Web UI: файловые runtime-логи
|
||||||
|
|
||||||
- **Мониторинг** → вкладка **«Файловые логи»** (`/monitoring?tab=runtime-logs`).
|
- **Мониторинг** → вкладка **«Файловые логи»** (`/monitoring?tab=runtime-logs`).
|
||||||
- Подвкладки: **Файлы** (список, preview хвоста, очистка operator) и **Audit очистки**.
|
- Подвкладки: **Файлы** (список, preview хвоста, очистка operator) и **Audit очистки** (история из БД, в т.ч. `auto:scheduler`).
|
||||||
- При **503** на списке файлов: FS API недоступен (не `evobgp-all` или нет volume); audit из БД может отображаться отдельно.
|
- Автоочистка: **Параметры** → **Файловые логи** — порог MiB, UTC cron, режим truncate/delete; scheduler в `evobgp-all`.
|
||||||
|
- При **503** на списке файлов: FS API недоступен (не `evobgp-all` или нет volume); `GET /v1/runtime-logs/cleanup-audit` работает без volume.
|
||||||
- Deploy: `EVOBGP_RUNTIME_LOGS_DIR`, bind-mount на `evobgp-all`, sidecar `stack-runtime-logs` — [quickstart.md](quickstart.md#файловые-runtime-логи-api-v1runtime-logs), [access.md](access.md).
|
- Deploy: `EVOBGP_RUNTIME_LOGS_DIR`, bind-mount на `evobgp-all`, sidecar `stack-runtime-logs` — [quickstart.md](quickstart.md#файловые-runtime-логи-api-v1runtime-logs), [access.md](access.md).
|
||||||
|
|
||||||
## 7. Эксплуатация и runbook
|
## 7. Эксплуатация и runbook
|
||||||
|
|||||||
@@ -1168,6 +1168,54 @@ components:
|
|||||||
has_more:
|
has_more:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
|
||||||
|
RuntimeLogAutoPolicy:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
max_file_bytes:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
schedule:
|
||||||
|
type: string
|
||||||
|
description: UTC cron (minute hour dom month dow).
|
||||||
|
mode:
|
||||||
|
$ref: "#/components/schemas/RuntimeLogCleanupMode"
|
||||||
|
|
||||||
|
RuntimeLogAutoEstimateItem:
|
||||||
|
type: object
|
||||||
|
required: [filename, size_bytes, would_cleanup]
|
||||||
|
properties:
|
||||||
|
filename:
|
||||||
|
type: string
|
||||||
|
size_bytes:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
would_cleanup:
|
||||||
|
type: boolean
|
||||||
|
skip_reason:
|
||||||
|
type: string
|
||||||
|
description: under_threshold, too_large, или текст ошибки.
|
||||||
|
|
||||||
|
RuntimeLogAutoEstimate:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
policy:
|
||||||
|
$ref: "#/components/schemas/RuntimeLogAutoPolicy"
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/RuntimeLogAutoEstimateItem"
|
||||||
|
would_count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
|
||||||
|
RuntimeLogAutoRunResult:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
description: |
|
||||||
|
dry_run, trigger, policy, cleaned[], skipped[], cleaned_count, skipped_count.
|
||||||
|
|
||||||
BirdLocalStatus:
|
BirdLocalStatus:
|
||||||
type: object
|
type: object
|
||||||
description: Статус локального BIRD на хосте API (GET /v1/bird/status).
|
description: Статус локального BIRD на хосте API (GET /v1/bird/status).
|
||||||
@@ -1212,6 +1260,70 @@ components:
|
|||||||
format: date-time
|
format: date-time
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
|
|
||||||
|
RevisionPruneEstimate:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- retention_minutes
|
||||||
|
- cutoff_at
|
||||||
|
- revision_count
|
||||||
|
- prefix_row_count
|
||||||
|
- orphan_snapshot_count
|
||||||
|
- bytes_estimate
|
||||||
|
properties:
|
||||||
|
retention_minutes:
|
||||||
|
type: integer
|
||||||
|
minimum: 15
|
||||||
|
maximum: 43200
|
||||||
|
cutoff_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
revision_count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
prefix_row_count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
description: Строки prefix_snapshot_row в освобождаемых снимках.
|
||||||
|
orphan_snapshot_count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
bytes_estimate:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
minimum: 0
|
||||||
|
description: Ориентировочный логический объём данных (байты).
|
||||||
|
|
||||||
|
RevisionPruneResult:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- deleted_revisions
|
||||||
|
- deleted_prefix_snapshots
|
||||||
|
- deleted_prefix_rows
|
||||||
|
- bytes_estimate
|
||||||
|
properties:
|
||||||
|
deleted_revisions:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
deleted_prefix_snapshots:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
deleted_prefix_rows:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
bytes_estimate:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
minimum: 0
|
||||||
|
|
||||||
|
RevisionPruneRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
retention_minutes:
|
||||||
|
type: integer
|
||||||
|
minimum: 15
|
||||||
|
maximum: 43200
|
||||||
|
description: TTL в минутах; если не задан — из revision_retention_minutes tenant settings.
|
||||||
|
|
||||||
PrefixSnapshotItem:
|
PrefixSnapshotItem:
|
||||||
type: object
|
type: object
|
||||||
description: >
|
description: >
|
||||||
@@ -1324,6 +1436,23 @@ components:
|
|||||||
bird_bgp_source_ipv6:
|
bird_bgp_source_ipv6:
|
||||||
type: string
|
type: string
|
||||||
description: Зарезервировано; в текущей генерации BGP не используется.
|
description: Зарезервировано; в текущей генерации BGP не используется.
|
||||||
|
revision_retention_minutes:
|
||||||
|
type: integer
|
||||||
|
minimum: 15
|
||||||
|
maximum: 43200
|
||||||
|
runtime_logs_auto_enabled:
|
||||||
|
type: boolean
|
||||||
|
description: Автоочистка *.log на evobgp-all по расписанию (только при FS volume).
|
||||||
|
runtime_logs_max_file_mb:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 512
|
||||||
|
description: Truncate/delete файлов строго больше порога (MiB).
|
||||||
|
runtime_logs_auto_schedule:
|
||||||
|
type: string
|
||||||
|
description: UTC cron для автоочистки (по умолчанию `0 */6 * * *`).
|
||||||
|
runtime_logs_auto_mode:
|
||||||
|
$ref: "#/components/schemas/RuntimeLogCleanupMode"
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
|
|
||||||
RevisionDiff:
|
RevisionDiff:
|
||||||
@@ -2823,6 +2952,66 @@ paths:
|
|||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/DefaultProblem"
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/v1/revisions/prune-estimate:
|
||||||
|
get:
|
||||||
|
tags: [Revisions]
|
||||||
|
summary: Оценка очистки ревизий по retention
|
||||||
|
description: >
|
||||||
|
Считает ревизии и ориентировочный объём данных, которые будут удалены при prune
|
||||||
|
(те же правила, что applyRevisionRetention: последняя ревизия tenant и раскатанные на спикерах сохраняются).
|
||||||
|
operationId: getRevisionPruneEstimate
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TenantId"
|
||||||
|
- name: retention_minutes
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 15
|
||||||
|
maximum: 43200
|
||||||
|
description: TTL в минутах; если не задан — из tenant settings (default 30d).
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Оценка.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/RevisionPruneEstimate"
|
||||||
|
"422":
|
||||||
|
$ref: "#/components/responses/UnprocessableEntity"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/v1/revisions/prune:
|
||||||
|
post:
|
||||||
|
tags: [Revisions]
|
||||||
|
summary: Очистить старые ревизии (синхронно)
|
||||||
|
description: >
|
||||||
|
Удаляет ревизии старше cutoff по retention и GC неиспользуемых prefix_snapshot.
|
||||||
|
Operator-only.
|
||||||
|
operationId: pruneRevisions
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TenantId"
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/RevisionPruneRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Результат очистки.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/RevisionPruneResult"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/Forbidden"
|
||||||
|
"422":
|
||||||
|
$ref: "#/components/responses/UnprocessableEntity"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
/v1/revisions/{revision_id}:
|
/v1/revisions/{revision_id}:
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: "#/components/parameters/TenantId"
|
- $ref: "#/components/parameters/TenantId"
|
||||||
@@ -3992,6 +4181,63 @@ paths:
|
|||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/DefaultProblem"
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/v1/runtime-logs/auto-estimate:
|
||||||
|
get:
|
||||||
|
tags: [RuntimeLogs]
|
||||||
|
summary: Оценка автоочистки runtime log-файлов
|
||||||
|
description: |
|
||||||
|
Список файлов, которые будут затронуты текущей политикой tenant settings.
|
||||||
|
Требует evobgp-all с примонтированным каталогом runtime-logs.
|
||||||
|
operationId: estimateRuntimeLogAutoCleanup
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TenantId"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Успешно.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/RuntimeLogAutoEstimate"
|
||||||
|
"503":
|
||||||
|
description: FS API недоступен (не evobgp-all или нет volume).
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Problem"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/v1/runtime-logs/auto-run:
|
||||||
|
post:
|
||||||
|
tags: [RuntimeLogs]
|
||||||
|
summary: Запустить автоочистку runtime log-файлов
|
||||||
|
description: |
|
||||||
|
Немедленный прогон политики из tenant settings. `dry_run=true` — только оценка без FS-изменений.
|
||||||
|
Записи audit с `actor_prefix=auto:scheduler`.
|
||||||
|
operationId: runRuntimeLogAutoCleanup
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TenantId"
|
||||||
|
- name: dry_run
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Успешно.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/RuntimeLogAutoRunResult"
|
||||||
|
"503":
|
||||||
|
description: FS API недоступен.
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Problem"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
/v1/runtime-logs/cleanup-audit:
|
/v1/runtime-logs/cleanup-audit:
|
||||||
get:
|
get:
|
||||||
tags: [RuntimeLogs]
|
tags: [RuntimeLogs]
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ type Env struct {
|
|||||||
BrokerURL string
|
BrokerURL string
|
||||||
// RuntimeLogsDir is the absolute host path to Docker runtime log files (evobgp-all only).
|
// RuntimeLogsDir is the absolute host path to Docker runtime log files (evobgp-all only).
|
||||||
RuntimeLogsDir string
|
RuntimeLogsDir string
|
||||||
|
// RuntimeLogsPolicyTenant overrides which tenant global_settings drive auto-cleanup (optional).
|
||||||
|
RuntimeLogsPolicyTenant string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads EVOBGP_* environment variables with safe defaults.
|
// Load reads EVOBGP_* environment variables with safe defaults.
|
||||||
@@ -34,6 +36,7 @@ func Load() Env {
|
|||||||
e.GitSHA = strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA"))
|
e.GitSHA = strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA"))
|
||||||
e.DatabaseURL = strings.TrimSpace(os.Getenv("EVOBGP_DATABASE_URL"))
|
e.DatabaseURL = strings.TrimSpace(os.Getenv("EVOBGP_DATABASE_URL"))
|
||||||
e.BrokerURL = strings.TrimSpace(os.Getenv("EVOBGP_BROKER_URL"))
|
e.BrokerURL = strings.TrimSpace(os.Getenv("EVOBGP_BROKER_URL"))
|
||||||
|
e.RuntimeLogsPolicyTenant = strings.TrimSpace(os.Getenv("EVOBGP_RUNTIME_LOGS_POLICY_TENANT"))
|
||||||
if v := strings.TrimSpace(os.Getenv("EVOBGP_RUNTIME_LOGS_DIR")); v != "" {
|
if v := strings.TrimSpace(os.Getenv("EVOBGP_RUNTIME_LOGS_DIR")); v != "" {
|
||||||
if abs, err := filepath.Abs(v); err == nil {
|
if abs, err := filepath.Abs(v); err == nil {
|
||||||
e.RuntimeLogsDir = abs
|
e.RuntimeLogsDir = abs
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ func (s *Server) registerV1(m *http.ServeMux) {
|
|||||||
m.HandleFunc("POST /modules/{module_id}/refresh", s.handleModuleRefresh)
|
m.HandleFunc("POST /modules/{module_id}/refresh", s.handleModuleRefresh)
|
||||||
m.HandleFunc("POST /tenant/refresh", s.handleTenantRefresh)
|
m.HandleFunc("POST /tenant/refresh", s.handleTenantRefresh)
|
||||||
m.HandleFunc("GET /revisions", s.handleListRevisions)
|
m.HandleFunc("GET /revisions", s.handleListRevisions)
|
||||||
|
m.HandleFunc("GET /revisions/prune-estimate", s.handleRevisionPruneEstimate)
|
||||||
|
m.HandleFunc("POST /revisions/prune", s.handleRevisionPrune)
|
||||||
m.HandleFunc("GET /revisions/{revision_id}", s.handleGetRevision)
|
m.HandleFunc("GET /revisions/{revision_id}", s.handleGetRevision)
|
||||||
m.HandleFunc("GET /revisions/{revision_id}/preview", s.handleRevisionPreview)
|
m.HandleFunc("GET /revisions/{revision_id}/preview", s.handleRevisionPreview)
|
||||||
m.HandleFunc("GET /revisions/{revision_id}/diagnostic-log", s.handleRevisionDiagnosticLog)
|
m.HandleFunc("GET /revisions/{revision_id}/diagnostic-log", s.handleRevisionDiagnosticLog)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"evobgp/internal/importer"
|
"evobgp/internal/importer"
|
||||||
"evobgp/internal/pipeline"
|
"evobgp/internal/pipeline"
|
||||||
|
"evobgp/internal/runtimelogs"
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1117,6 +1118,10 @@ func (s *Server) handlePatchSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
body["revision_retention_minutes"] = v
|
body["revision_retention_minutes"] = v
|
||||||
}
|
}
|
||||||
|
if !runtimelogs.ValidateRuntimeLogsSettingsPatch(body) {
|
||||||
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid runtime_logs_* settings")
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := s.store.PatchGlobalSettings(a.TenantID, body); err != nil {
|
if err := s.store.PatchGlobalSettings(a.TenantID, body); err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/pipeline"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) resolveRevisionRetentionMinutesQuery(r *http.Request, tenantID string) (minutes int, ok bool) {
|
||||||
|
if raw := r.URL.Query().Get("retention_minutes"); raw != "" {
|
||||||
|
n, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if n < pipeline.RevisionMinTTLMin || n > pipeline.RevisionMaxTTLMin {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
return s.defaultRevisionRetentionMinutes(tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) defaultRevisionRetentionMinutes(tenantID string) (int, bool) {
|
||||||
|
settings, err := s.store.ListGlobalSettings(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return pipeline.ClampRevisionRetentionMinutes(0), true
|
||||||
|
}
|
||||||
|
return pipeline.ClampRevisionRetentionMinutes(pipeline.RevisionRetentionMinutesFromSettings(settings)), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) resolveRevisionRetentionMinutesBody(r *http.Request, tenantID string) (minutes int, ok bool) {
|
||||||
|
var body struct {
|
||||||
|
RetentionMinutes *int `json:"retention_minutes"`
|
||||||
|
}
|
||||||
|
if r.Body != nil && r.ContentLength != 0 {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if body.RetentionMinutes != nil {
|
||||||
|
m := *body.RetentionMinutes
|
||||||
|
if m < pipeline.RevisionMinTTLMin || m > pipeline.RevisionMaxTTLMin {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return m, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.defaultRevisionRetentionMinutes(tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRevisionPruneEstimate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a, ok := authFromContext(r.Context())
|
||||||
|
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
minutes, valid := s.resolveRevisionRetentionMinutesQuery(r, a.TenantID)
|
||||||
|
if !valid {
|
||||||
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "retention_minutes must be an integer in range 15..43200")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cutoff := pipeline.RevisionCutoffFromMinutes(minutes)
|
||||||
|
est, err := s.store.EstimateRevisionPrune(a.TenantID, cutoff, minutes)
|
||||||
|
if err != nil {
|
||||||
|
writeInternalError(w, "revision prune estimate", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"retention_minutes": est.RetentionMinutes,
|
||||||
|
"cutoff_at": est.CutoffAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
"revision_count": est.RevisionCount,
|
||||||
|
"prefix_row_count": est.PrefixRowCount,
|
||||||
|
"orphan_snapshot_count": est.OrphanSnapshotCount,
|
||||||
|
"bytes_estimate": est.BytesEstimate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRevisionPrune(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a, ok := authFromContext(r.Context())
|
||||||
|
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
minutes, valid := s.resolveRevisionRetentionMinutesBody(r, a.TenantID)
|
||||||
|
if !valid {
|
||||||
|
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "retention_minutes must be an integer in range 15..43200")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cutoff := pipeline.RevisionCutoffFromMinutes(minutes)
|
||||||
|
res, err := s.store.PruneRevisionsWithStats(a.TenantID, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
writeInternalError(w, "revision prune", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"deleted_revisions": res.DeletedRevisions,
|
||||||
|
"deleted_prefix_snapshots": res.DeletedPrefixSnapshots,
|
||||||
|
"deleted_prefix_rows": res.DeletedPrefixRows,
|
||||||
|
"bytes_estimate": res.BytesEstimate,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRevisionPruneEstimateViewerOK(t *testing.T) {
|
||||||
|
srv, err := New(Options{InsecureDev: true, SeedDemo: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer srv.Close()
|
||||||
|
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||||
|
mustSetTestAPIKeys(t, srv, "vwkey|"+tenant+"|viewer")
|
||||||
|
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/revisions/prune-estimate?retention_minutes=15", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer vwkey")
|
||||||
|
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: %s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := body["revision_count"]; !ok {
|
||||||
|
t.Fatalf("missing revision_count: %v", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevisionPruneViewerForbidden(t *testing.T) {
|
||||||
|
srv, err := New(Options{InsecureDev: true, SeedDemo: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer srv.Close()
|
||||||
|
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||||
|
mustSetTestAPIKeys(t, srv, "vwkey|"+tenant+"|viewer")
|
||||||
|
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/revisions/prune", strings.NewReader(`{"retention_minutes":43200}`))
|
||||||
|
req.Header.Set("Authorization", "Bearer vwkey")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := ts.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("status %d: %s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevisionPruneOperatorOK(t *testing.T) {
|
||||||
|
srv, err := New(Options{InsecureDev: true, SeedDemo: true})
|
||||||
|
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()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/revisions/prune", strings.NewReader(`{"retention_minutes":15}`))
|
||||||
|
req.Header.Set("Authorization", "Bearer opkey")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
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: %s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ func (s *Server) registerRuntimeLogsRoutes(m *http.ServeMux) {
|
|||||||
m.HandleFunc("GET /runtime-logs/files/{filename}", s.handleGetRuntimeLogTail)
|
m.HandleFunc("GET /runtime-logs/files/{filename}", s.handleGetRuntimeLogTail)
|
||||||
m.HandleFunc("DELETE /runtime-logs/files/{filename}", s.handleDeleteRuntimeLogFile)
|
m.HandleFunc("DELETE /runtime-logs/files/{filename}", s.handleDeleteRuntimeLogFile)
|
||||||
m.HandleFunc("GET /runtime-logs/cleanup-audit", s.handleListRuntimeLogCleanupAudit)
|
m.HandleFunc("GET /runtime-logs/cleanup-audit", s.handleListRuntimeLogCleanupAudit)
|
||||||
|
m.HandleFunc("GET /runtime-logs/auto-estimate", s.handleRuntimeLogAutoEstimate)
|
||||||
|
m.HandleFunc("POST /runtime-logs/auto-run", s.handleRuntimeLogAutoRun)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) requireRuntimeLogs(w http.ResponseWriter) bool {
|
func (s *Server) requireRuntimeLogs(w http.ResponseWriter) bool {
|
||||||
@@ -144,6 +146,79 @@ func (s *Server) handleDeleteRuntimeLogFile(w http.ResponseWriter, r *http.Reque
|
|||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) runtimeLogAutoPolicy(w http.ResponseWriter, r *http.Request, tenantID string) (runtimelogs.AutoPolicy, bool) {
|
||||||
|
settings, err := s.store.ListGlobalSettings(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
writeInternalError(w, "runtime_logs_policy", err)
|
||||||
|
return runtimelogs.AutoPolicy{}, false
|
||||||
|
}
|
||||||
|
return runtimelogs.PolicyFromSettings(settings), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRuntimeLogAutoEstimate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a, ok := authFromContext(r.Context())
|
||||||
|
if !ok || !s.requireAtLeast(w, a, "operator") || !s.requireRuntimeLogs(w) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
policy, ok := s.runtimeLogAutoPolicy(w, r, a.TenantID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := runtimelogs.EstimateAutoCleanup(s.runtimeLogs, policy)
|
||||||
|
if err != nil {
|
||||||
|
writeRuntimeLogsErr(w, "runtime_logs_auto_estimate", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]map[string]any, 0, len(items))
|
||||||
|
var wouldCount int
|
||||||
|
for _, it := range items {
|
||||||
|
if it.WouldCleanup {
|
||||||
|
wouldCount++
|
||||||
|
}
|
||||||
|
row := map[string]any{
|
||||||
|
"filename": it.Filename,
|
||||||
|
"size_bytes": it.SizeBytes,
|
||||||
|
"would_cleanup": it.WouldCleanup,
|
||||||
|
}
|
||||||
|
if it.SkipReason != "" {
|
||||||
|
row["skip_reason"] = it.SkipReason
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"policy": map[string]any{
|
||||||
|
"enabled": policy.Enabled,
|
||||||
|
"max_file_bytes": policy.MaxFileBytes,
|
||||||
|
"schedule": policy.Schedule,
|
||||||
|
"mode": policy.Mode,
|
||||||
|
},
|
||||||
|
"items": out,
|
||||||
|
"would_count": wouldCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRuntimeLogAutoRun(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a, ok := authFromContext(r.Context())
|
||||||
|
if !ok || !s.requireAtLeast(w, a, "operator") || !s.requireRuntimeLogs(w) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
policy, ok := s.runtimeLogAutoPolicy(w, r, a.TenantID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dryRun := r.URL.Query().Get("dry_run") == "true"
|
||||||
|
result, err := runtimelogs.RunAutoCleanup(r.Context(), runtimelogs.AutoCleanupDeps{
|
||||||
|
Service: s.runtimeLogs,
|
||||||
|
Store: s.store,
|
||||||
|
TenantID: a.TenantID,
|
||||||
|
}, policy, dryRun, "manual")
|
||||||
|
if err != nil {
|
||||||
|
writeRuntimeLogsErr(w, "runtime_logs_auto_run", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleListRuntimeLogCleanupAudit(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListRuntimeLogCleanupAudit(w http.ResponseWriter, r *http.Request) {
|
||||||
a, ok := authFromContext(r.Context())
|
a, ok := authFromContext(r.Context())
|
||||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func TestRuntimeLogsHappyPath(t *testing.T) {
|
|||||||
ServiceName: runtimelogs.ServiceNameAll,
|
ServiceName: runtimelogs.ServiceNameAll,
|
||||||
})
|
})
|
||||||
handler := srv.Handler()
|
handler := srv.Handler()
|
||||||
tenant := "00000000-0000-0000-0000-000000000001"
|
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||||
mustSetTestAPIKeys(t, srv, "vwkey|"+tenant+"|viewer,opkey|"+tenant+"|operator")
|
mustSetTestAPIKeys(t, srv, "vwkey|"+tenant+"|viewer,opkey|"+tenant+"|operator")
|
||||||
|
|
||||||
t.Run("list", func(t *testing.T) {
|
t.Run("list", func(t *testing.T) {
|
||||||
@@ -146,4 +146,46 @@ func TestRuntimeLogsHappyPath(t *testing.T) {
|
|||||||
t.Fatalf("expected audit entry, body=%s", auditRec.Body.String())
|
t.Fatalf("expected audit entry, body=%s", auditRec.Body.String())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("auto estimate and run", func(t *testing.T) {
|
||||||
|
bigPath := filepath.Join(dir, "big.log")
|
||||||
|
if err := os.WriteFile(bigPath, make([]byte, 2*1024*1024), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := srv.store.PatchGlobalSettings(tenant, map[string]any{
|
||||||
|
runtimelogs.KeyMaxFileMB: 1,
|
||||||
|
runtimelogs.KeyAutoMode: "truncate",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
estReq := httptest.NewRequest(http.MethodGet, "/v1/runtime-logs/auto-estimate", nil)
|
||||||
|
estReq.Header.Set("Authorization", "Bearer opkey")
|
||||||
|
estRec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(estRec, estReq)
|
||||||
|
if estRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("estimate status=%d body=%s", estRec.Code, estRec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(estRec.Body.String(), `"would_count":1`) {
|
||||||
|
t.Fatalf("expected would_count=1, body=%s", estRec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
runReq := httptest.NewRequest(http.MethodPost, "/v1/runtime-logs/auto-run", nil)
|
||||||
|
runReq.Header.Set("Authorization", "Bearer opkey")
|
||||||
|
runRec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(runRec, runReq)
|
||||||
|
if runRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("auto-run status=%d body=%s", runRec.Code, runRec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(runRec.Body.String(), "auto:scheduler") && !strings.Contains(runRec.Body.String(), `"cleaned_count":1`) {
|
||||||
|
t.Fatalf("unexpected auto-run body=%s", runRec.Body.String())
|
||||||
|
}
|
||||||
|
st, err := os.Stat(bigPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.Size() != 0 {
|
||||||
|
t.Fatalf("expected truncated big.log, size=%d", st.Size())
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
+34
-23
@@ -21,18 +21,19 @@ import (
|
|||||||
|
|
||||||
// Server implements EvoBGP control-plane HTTP API.
|
// Server implements EvoBGP control-plane HTTP API.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
store store.Backend
|
store store.Backend
|
||||||
pgPool *pgxpool.Pool
|
pgPool *pgxpool.Pool
|
||||||
pgMonitor *pgmonitor.Service
|
pgMonitor *pgmonitor.Service
|
||||||
maintConfig *maintenance.ConfigProvider
|
maintConfig *maintenance.ConfigProvider
|
||||||
maintStats *maintenance.DBStatsProvider
|
maintStats *maintenance.DBStatsProvider
|
||||||
jobs *jobs.Registry
|
jobs *jobs.Registry
|
||||||
bundlePriv ed25519.PrivateKey
|
bundlePriv ed25519.PrivateKey
|
||||||
keyResolver *apiKeyResolver
|
keyResolver *apiKeyResolver
|
||||||
corsOrigins []string
|
corsOrigins []string
|
||||||
cdnHTTP *http.Client
|
cdnHTTP *http.Client
|
||||||
runtimeLogs *runtimelogs.Service
|
runtimeLogs *runtimelogs.Service
|
||||||
mux *http.ServeMux
|
runtimeLogsPolicyTenant string
|
||||||
|
mux *http.ServeMux
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options configures the API server.
|
// Options configures the API server.
|
||||||
@@ -44,6 +45,8 @@ type Options struct {
|
|||||||
SeedDemo bool
|
SeedDemo bool
|
||||||
BundleSeedHex string
|
BundleSeedHex string
|
||||||
CORSAllowedOrigins string
|
CORSAllowedOrigins string
|
||||||
|
// RuntimeLogsPolicyTenant overrides tenant for auto-cleanup scheduler settings (optional).
|
||||||
|
RuntimeLogsPolicyTenant string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs Server and wiring for async jobs.
|
// New constructs Server and wiring for async jobs.
|
||||||
@@ -81,17 +84,18 @@ func New(opts Options) (*Server, error) {
|
|||||||
maintStats = maintenance.NewDBStatsProvider(pgMon)
|
maintStats = maintenance.NewDBStatsProvider(pgMon)
|
||||||
}
|
}
|
||||||
s := &Server{
|
s := &Server{
|
||||||
store: backend,
|
store: backend,
|
||||||
pgPool: pool,
|
pgPool: pool,
|
||||||
pgMonitor: pgMon,
|
pgMonitor: pgMon,
|
||||||
maintConfig: maintCfg,
|
maintConfig: maintCfg,
|
||||||
maintStats: maintStats,
|
maintStats: maintStats,
|
||||||
jobs: reg,
|
jobs: reg,
|
||||||
bundlePriv: priv,
|
bundlePriv: priv,
|
||||||
keyResolver: resolver,
|
keyResolver: resolver,
|
||||||
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
|
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
|
||||||
cdnHTTP: NewCDNHTTPClient(),
|
cdnHTTP: NewCDNHTTPClient(),
|
||||||
runtimeLogs: runtimelogs.NewService(runtimelogs.ConfigFromEnv()),
|
runtimeLogs: runtimelogs.NewService(runtimelogs.ConfigFromEnv()),
|
||||||
|
runtimeLogsPolicyTenant: strings.TrimSpace(opts.RuntimeLogsPolicyTenant),
|
||||||
}
|
}
|
||||||
s.mux = http.NewServeMux()
|
s.mux = http.NewServeMux()
|
||||||
s.registerRoutes()
|
s.registerRoutes()
|
||||||
@@ -126,4 +130,11 @@ func (s *Server) StartBackground(ctx context.Context) {
|
|||||||
})
|
})
|
||||||
}, 30*time.Second)
|
}, 30*time.Second)
|
||||||
}
|
}
|
||||||
|
if s != nil && s.runtimeLogs != nil && s.store != nil {
|
||||||
|
runtimelogs.StartAutoCleanupScheduler(ctx, runtimelogs.SchedulerDeps{
|
||||||
|
Service: s.runtimeLogs,
|
||||||
|
Store: s.store,
|
||||||
|
PolicyTenant: s.runtimeLogsPolicyTenant,
|
||||||
|
}, 30*time.Second)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -31,10 +31,7 @@ const (
|
|||||||
birdFilterNameV4 = "evobgp_export_v4"
|
birdFilterNameV4 = "evobgp_export_v4"
|
||||||
birdFilterNameV6 = "evobgp_export_v6"
|
birdFilterNameV6 = "evobgp_export_v6"
|
||||||
auxBirdFullExpanded = "_bird_full_expanded.conf"
|
auxBirdFullExpanded = "_bird_full_expanded.conf"
|
||||||
revisionTTLKey = "revision_retention_minutes"
|
revisionTTLKey = RevisionRetentionKey
|
||||||
revisionMinTTLMin = 15
|
|
||||||
revisionMaxTTLMin = 30 * 24 * 60
|
|
||||||
revisionDefaultTTL = 30 * 24 * time.Hour
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuxBirdFullExpandedKey returns the preview map key for the expanded BIRD config (generated on demand).
|
// AuxBirdFullExpandedKey returns the preview map key for the expanded BIRD config (generated on demand).
|
||||||
@@ -969,17 +966,7 @@ func applyRevisionRetention(st store.Backend, tenantID string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ttl := revisionDefaultTTL
|
cutoff := RevisionRetentionCutoff(settings)
|
||||||
if minutes := intFromSettingsMap(settings, revisionTTLKey); minutes > 0 {
|
|
||||||
if minutes < revisionMinTTLMin {
|
|
||||||
minutes = revisionMinTTLMin
|
|
||||||
}
|
|
||||||
if minutes > revisionMaxTTLMin {
|
|
||||||
minutes = revisionMaxTTLMin
|
|
||||||
}
|
|
||||||
ttl = time.Duration(minutes) * time.Minute
|
|
||||||
}
|
|
||||||
cutoff := time.Now().UTC().Add(-ttl)
|
|
||||||
_, _ = st.PruneRevisionsBefore(tenantID, cutoff)
|
_, _ = st.PruneRevisionsBefore(tenantID, cutoff)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// RevisionRetentionKey is the global_settings KV for revision TTL (minutes).
|
||||||
|
RevisionRetentionKey = "revision_retention_minutes"
|
||||||
|
RevisionMinTTLMin = 15
|
||||||
|
RevisionMaxTTLMin = 30 * 24 * 60
|
||||||
|
RevisionDefaultTTL = 30 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// RevisionCutoffFromMinutes returns created_at cutoff for revision prune (UTC now minus clamped TTL).
|
||||||
|
func RevisionCutoffFromMinutes(minutes int) time.Time {
|
||||||
|
ttl := RevisionDefaultTTL
|
||||||
|
if minutes > 0 {
|
||||||
|
m := minutes
|
||||||
|
if m < RevisionMinTTLMin {
|
||||||
|
m = RevisionMinTTLMin
|
||||||
|
}
|
||||||
|
if m > RevisionMaxTTLMin {
|
||||||
|
m = RevisionMaxTTLMin
|
||||||
|
}
|
||||||
|
ttl = time.Duration(m) * time.Minute
|
||||||
|
}
|
||||||
|
return time.Now().UTC().Add(-ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevisionRetentionMinutesFromSettings reads revision_retention_minutes from tenant KV (0 if unset).
|
||||||
|
func RevisionRetentionMinutesFromSettings(settings map[string]any) int {
|
||||||
|
if settings == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
v, ok := settings[RevisionRetentionKey]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch x := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return int(x)
|
||||||
|
case int:
|
||||||
|
return x
|
||||||
|
case int64:
|
||||||
|
return int(x)
|
||||||
|
case string:
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(x))
|
||||||
|
if err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevisionRetentionCutoff resolves tenant revision_retention_minutes from settings (default 30d).
|
||||||
|
func RevisionRetentionCutoff(settings map[string]any) time.Time {
|
||||||
|
return RevisionCutoffFromMinutes(RevisionRetentionMinutesFromSettings(settings))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClampRevisionRetentionMinutes normalizes user input to the allowed range.
|
||||||
|
func ClampRevisionRetentionMinutes(minutes int) int {
|
||||||
|
if minutes <= 0 {
|
||||||
|
return int(RevisionDefaultTTL / time.Minute)
|
||||||
|
}
|
||||||
|
if minutes < RevisionMinTTLMin {
|
||||||
|
return RevisionMinTTLMin
|
||||||
|
}
|
||||||
|
if minutes > RevisionMaxTTLMin {
|
||||||
|
return RevisionMaxTTLMin
|
||||||
|
}
|
||||||
|
return minutes
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClampRevisionRetentionMinutes(t *testing.T) {
|
||||||
|
if got := ClampRevisionRetentionMinutes(0); got != int(RevisionDefaultTTL/time.Minute) {
|
||||||
|
t.Fatalf("default: got %d", got)
|
||||||
|
}
|
||||||
|
if got := ClampRevisionRetentionMinutes(5); got != RevisionMinTTLMin {
|
||||||
|
t.Fatalf("min clamp: got %d", got)
|
||||||
|
}
|
||||||
|
if got := ClampRevisionRetentionMinutes(999999); got != RevisionMaxTTLMin {
|
||||||
|
t.Fatalf("max clamp: got %d", got)
|
||||||
|
}
|
||||||
|
if got := ClampRevisionRetentionMinutes(120); got != 120 {
|
||||||
|
t.Fatalf("unchanged: got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevisionCutoffFromMinutes(t *testing.T) {
|
||||||
|
before := time.Now().UTC()
|
||||||
|
cutoff := RevisionCutoffFromMinutes(60)
|
||||||
|
after := time.Now().UTC().Add(-59 * time.Minute)
|
||||||
|
if cutoff.After(before.Add(-59*time.Minute)) || cutoff.Before(after.Add(-2*time.Minute)) {
|
||||||
|
t.Fatalf("cutoff out of range: %v", cutoff)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -970,43 +970,6 @@ func (p *Postgres) RevisionDiff(tenantID, aID, bID string) (map[string]any, erro
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Postgres) PruneRevisionsBefore(tenantID string, cutoff time.Time) (int, error) {
|
|
||||||
ctx := context.Background()
|
|
||||||
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 total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Postgres) SetLastAppliedRevision(tenantID, speakerID, revisionID string) error {
|
func (p *Postgres) SetLastAppliedRevision(tenantID, speakerID, revisionID string) error {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
tag, err := p.pool.Exec(ctx, `
|
tag, err := p.pool.Exec(ctx, `
|
||||||
|
|||||||
@@ -53,21 +53,8 @@ func (p *Postgres) ensurePrefixSnapshot(ctx context.Context, db execQuerier, con
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
hash := normalizeSnapshotHash(contentHash)
|
hash := normalizeSnapshotHash(contentHash)
|
||||||
var existing string
|
snapID, err := p.resolvePrefixSnapshotID(ctx, db, hash)
|
||||||
err := db.QueryRow(ctx, `SELECT id::text FROM prefix_snapshot WHERE content_hash = $1`, hash).Scan(&existing)
|
if err != nil {
|
||||||
if err == nil && existing != "" {
|
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
snapID := uuid.NewString()
|
|
||||||
if _, err := db.Exec(ctx, `
|
|
||||||
INSERT INTO prefix_snapshot (id, content_hash) VALUES ($1::uuid, $2)
|
|
||||||
ON CONFLICT (content_hash) DO NOTHING`, snapID, hash); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := db.QueryRow(ctx, `SELECT id::text FROM prefix_snapshot WHERE content_hash = $1`, hash).Scan(&snapID); err != nil {
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
var rowCount int
|
var rowCount int
|
||||||
@@ -94,6 +81,27 @@ func (p *Postgres) ensurePrefixSnapshot(ctx context.Context, db execQuerier, con
|
|||||||
return snapID, nil
|
return snapID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) resolvePrefixSnapshotID(ctx context.Context, db execQuerier, hash string) (string, error) {
|
||||||
|
var existing string
|
||||||
|
err := db.QueryRow(ctx, `SELECT id::text FROM prefix_snapshot WHERE content_hash = $1`, hash).Scan(&existing)
|
||||||
|
if err == nil && existing != "" {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
snapID := uuid.NewString()
|
||||||
|
if _, err := db.Exec(ctx, `
|
||||||
|
INSERT INTO prefix_snapshot (id, content_hash) VALUES ($1::uuid, $2)
|
||||||
|
ON CONFLICT (content_hash) DO NOTHING`, snapID, hash); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := db.QueryRow(ctx, `SELECT id::text FROM prefix_snapshot WHERE content_hash = $1`, hash).Scan(&snapID); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return snapID, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Postgres) listSnapshotPrefixes(ctx context.Context, snapshotID, cursor string, limit int) ([]store.PrefixRow, string, bool) {
|
func (p *Postgres) listSnapshotPrefixes(ctx context.Context, snapshotID, cursor string, limit int) ([]store.PrefixRow, string, bool) {
|
||||||
afterOrd, off, useOffset := store.ParsePrefixPageCursor(cursor)
|
afterOrd, off, useOffset := store.ParsePrefixPageCursor(cursor)
|
||||||
var rows pgx.Rows
|
var rows pgx.Rows
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"evobgp/internal/db"
|
||||||
|
"evobgp/internal/store"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnsurePrefixSnapshotFillsEmptyExistingIntegration(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := normalizeSnapshotHash("sha256:empty-fill-" + uuid.NewString())
|
||||||
|
snapID := uuid.NewString()
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO prefix_snapshot (id, content_hash) VALUES ($1::uuid, $2)`, snapID, hash); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM prefix_snapshot WHERE id = $1::uuid`, snapID)
|
||||||
|
})
|
||||||
|
|
||||||
|
tx, err := pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
got, err := pg.ensurePrefixSnapshot(ctx, tx, "sha256:"+hash, []store.PrefixRow{
|
||||||
|
{Prefix: "203.0.113.1/32", Source: "test"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != snapID {
|
||||||
|
t.Fatalf("snap id: got %q want %q", got, snapID)
|
||||||
|
}
|
||||||
|
var rowCount int
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*)::int FROM prefix_snapshot_row WHERE snapshot_id = $1::uuid`, snapID).Scan(&rowCount); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rowCount != 1 {
|
||||||
|
t.Fatalf("row count: got %d", rowCount)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsurePrefixSnapshotIdempotentIntegration(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentHash := "sha256:idempotent-" + uuid.NewString()
|
||||||
|
prefixes := []store.PrefixRow{{Prefix: "198.51.100.0/24", Source: "test"}}
|
||||||
|
|
||||||
|
tx1, err := pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
id1, err := pg.ensurePrefixSnapshot(ctx, tx1, contentHash, prefixes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tx1.Commit(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM prefix_snapshot WHERE id = $1::uuid`, id1)
|
||||||
|
})
|
||||||
|
|
||||||
|
tx2, err := pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx2.Rollback(ctx) }()
|
||||||
|
id2, err := pg.ensurePrefixSnapshot(ctx, tx2, contentHash, prefixes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if id1 != id2 {
|
||||||
|
t.Fatalf("ids differ: %q vs %q", id1, id2)
|
||||||
|
}
|
||||||
|
var rowCount int
|
||||||
|
if err := tx2.QueryRow(ctx, `SELECT COUNT(*)::int FROM prefix_snapshot_row WHERE snapshot_id = $1::uuid`, id1).Scan(&rowCount); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rowCount != 1 {
|
||||||
|
t.Fatalf("expected 1 row, got %d", rowCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const revisionPruneBatchSize = 50
|
||||||
|
|
||||||
|
// sqlPrunableRevisionsWhere appends prunable revision predicates (tenant + cutoff params).
|
||||||
|
func sqlPrunableRevisionsWhere(tenantParam, cutoffParam string) string {
|
||||||
|
return `tenant_id = ` + tenantParam + `
|
||||||
|
AND created_at < ` + cutoffParam + `
|
||||||
|
AND id <> (
|
||||||
|
SELECT id FROM config_revision
|
||||||
|
WHERE tenant_id = ` + tenantParam + `
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM bgp_speaker AS sp
|
||||||
|
WHERE sp.tenant_id = ` + tenantParam + `
|
||||||
|
AND (sp.last_applied_revision_id = config_revision.id OR sp.published_revision_id = config_revision.id)
|
||||||
|
)`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) EstimateRevisionPrune(tenantID string, cutoff time.Time, retentionMinutes int) (store.RevisionPruneEstimate, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
out := store.RevisionPruneEstimate{
|
||||||
|
RetentionMinutes: retentionMinutes,
|
||||||
|
CutoffAt: cutoff.UTC(),
|
||||||
|
}
|
||||||
|
where := sqlPrunableRevisionsWhere("$1", "$2")
|
||||||
|
var revBytes, previewBytes, rmpBytes, snapRowBytes int64
|
||||||
|
err := p.pool.QueryRow(ctx, `
|
||||||
|
WITH prunable AS (
|
||||||
|
SELECT id, prefix_snapshot_id, content_hash, meta_json
|
||||||
|
FROM config_revision
|
||||||
|
WHERE `+where+`
|
||||||
|
),
|
||||||
|
freed_snaps AS (
|
||||||
|
SELECT DISTINCT pr.prefix_snapshot_id AS snap_id
|
||||||
|
FROM prunable pr
|
||||||
|
WHERE pr.prefix_snapshot_id IS NOT NULL
|
||||||
|
EXCEPT
|
||||||
|
SELECT DISTINCT cr.prefix_snapshot_id
|
||||||
|
FROM config_revision cr
|
||||||
|
WHERE cr.prefix_snapshot_id IS NOT NULL
|
||||||
|
AND cr.id NOT IN (SELECT id FROM prunable)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*)::int FROM prunable),
|
||||||
|
COALESCE((SELECT SUM(octet_length(content_hash) + octet_length(meta_json::text))::bigint FROM prunable), 0),
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(octet_length(value))::bigint
|
||||||
|
FROM config_revision_preview p
|
||||||
|
JOIN prunable pr ON pr.id = p.revision_id
|
||||||
|
CROSS JOIN LATERAL jsonb_each_text(COALESCE(p.fragments, '{}'::jsonb))
|
||||||
|
), 0),
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(octet_length(rmp.prefix) + octet_length(COALESCE(rmp.source, '')))::bigint
|
||||||
|
FROM revision_materialized_prefix rmp
|
||||||
|
WHERE rmp.revision_id IN (SELECT id FROM prunable)
|
||||||
|
), 0),
|
||||||
|
(SELECT COUNT(*)::int FROM freed_snaps),
|
||||||
|
COALESCE((SELECT COUNT(*)::int FROM prefix_snapshot_row psr WHERE psr.snapshot_id IN (SELECT snap_id FROM freed_snaps)), 0),
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(octet_length(psr.prefix) + octet_length(COALESCE(psr.source, '')))::bigint
|
||||||
|
FROM prefix_snapshot_row psr
|
||||||
|
WHERE psr.snapshot_id IN (SELECT snap_id FROM freed_snaps)
|
||||||
|
), 0)`,
|
||||||
|
tenantID, cutoff.UTC()).Scan(
|
||||||
|
&out.RevisionCount,
|
||||||
|
&revBytes,
|
||||||
|
&previewBytes,
|
||||||
|
&rmpBytes,
|
||||||
|
&out.OrphanSnapshotCount,
|
||||||
|
&out.PrefixRowCount,
|
||||||
|
&snapRowBytes,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.BytesEstimate = revBytes + previewBytes + rmpBytes + snapRowBytes
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) PruneRevisionsWithStats(tenantID string, cutoff time.Time) (store.RevisionPruneResult, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
est, err := p.EstimateRevisionPrune(tenantID, cutoff, 0)
|
||||||
|
if err != nil {
|
||||||
|
return store.RevisionPruneResult{}, err
|
||||||
|
}
|
||||||
|
out := store.RevisionPruneResult{BytesEstimate: est.BytesEstimate}
|
||||||
|
|
||||||
|
where := sqlPrunableRevisionsWhere("$1", "$2")
|
||||||
|
for {
|
||||||
|
cmd, err := p.pool.Exec(ctx, `
|
||||||
|
DELETE FROM config_revision AS cr
|
||||||
|
WHERE cr.id IN (
|
||||||
|
SELECT id FROM config_revision
|
||||||
|
WHERE `+where+`
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT $3
|
||||||
|
)`, tenantID, cutoff.UTC(), revisionPruneBatchSize)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
n := int(cmd.RowsAffected())
|
||||||
|
out.DeletedRevisions += n
|
||||||
|
if n < revisionPruneBatchSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
snaps, rows, err := p.pruneUnreferencedPrefixSnapshots(ctx, revisionPruneBatchSize)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.DeletedPrefixSnapshots += snaps
|
||||||
|
out.DeletedPrefixRows += rows
|
||||||
|
if snaps < revisionPruneBatchSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) PruneRevisionsBefore(tenantID string, cutoff time.Time) (int, error) {
|
||||||
|
res, err := p.PruneRevisionsWithStats(tenantID, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.DeletedRevisions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) pruneUnreferencedPrefixSnapshots(ctx context.Context, batchSize int) (deletedSnapshots int, deletedRows int, err error) {
|
||||||
|
if !prefixSnapshotTableExists(ctx, p.pool) {
|
||||||
|
return 0, 0, nil
|
||||||
|
}
|
||||||
|
var snapCount, rowCount int
|
||||||
|
err = p.pool.QueryRow(ctx, `
|
||||||
|
WITH doomed AS (
|
||||||
|
SELECT ps.id FROM prefix_snapshot ps
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM config_revision cr WHERE cr.prefix_snapshot_id = ps.id
|
||||||
|
)
|
||||||
|
LIMIT $1
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*)::int FROM doomed),
|
||||||
|
(SELECT COUNT(*)::int FROM prefix_snapshot_row psr WHERE psr.snapshot_id IN (SELECT id FROM doomed))`,
|
||||||
|
batchSize).Scan(&snapCount, &rowCount)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
if snapCount == 0 {
|
||||||
|
return 0, 0, nil
|
||||||
|
}
|
||||||
|
_, err = p.pool.Exec(ctx, `
|
||||||
|
DELETE FROM prefix_snapshot
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT ps.id FROM prefix_snapshot ps
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM config_revision cr WHERE cr.prefix_snapshot_id = ps.id
|
||||||
|
)
|
||||||
|
LIMIT $1
|
||||||
|
)`, batchSize)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return snapCount, rowCount, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"evobgp/internal/db"
|
||||||
|
"evobgp/internal/pipeline"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPostgresPruneUnreferencedPrefixSnapshotsIntegration(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
snapID := uuid.NewString()
|
||||||
|
hash := normalizeSnapshotHash("sha256:test-orphan-" + snapID)
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO prefix_snapshot (id, content_hash) VALUES ($1::uuid, $2)`, snapID, hash); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO prefix_snapshot_row (snapshot_id, ord, prefix, source)
|
||||||
|
VALUES ($1::uuid, 0, '203.0.113.0/24', 'test')`, snapID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
snaps, rows, err := pg.pruneUnreferencedPrefixSnapshots(ctx, 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if snaps < 1 || rows < 1 {
|
||||||
|
t.Fatalf("expected orphan cleanup, got snaps=%d rows=%d", snaps, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
var n int
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT COUNT(*)::int FROM prefix_snapshot WHERE id = $1::uuid`, snapID).Scan(&n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("snapshot still exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresEstimateRevisionPruneIntegration(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 := uuid.NewString()
|
||||||
|
cutoff := pipeline.RevisionCutoffFromMinutes(15)
|
||||||
|
est, err := pg.EstimateRevisionPrune(tenant, cutoff, 15)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if est.RevisionCount != 0 {
|
||||||
|
t.Fatalf("expected 0 revisions for empty tenant, got %d", est.RevisionCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,7 +67,7 @@ func (p *Postgres) ListRuntimeLogCleanupAudit(tenantID, cursor string, limit int
|
|||||||
var sizeAfter *int64
|
var sizeAfter *int64
|
||||||
if err := rows.Scan(&r.ID, &r.TenantID, &r.ActorPrefix, &r.Filename, &r.Action,
|
if err := rows.Scan(&r.ID, &r.TenantID, &r.ActorPrefix, &r.Filename, &r.Action,
|
||||||
&r.SizeBefore, &sizeAfter, &detailRaw, &r.CreatedAt); err != nil {
|
&r.SizeBefore, &sizeAfter, &detailRaw, &r.CreatedAt); err != nil {
|
||||||
continue
|
return nil, "", false, err
|
||||||
}
|
}
|
||||||
r.SizeAfter = sizeAfter
|
r.SizeAfter = sizeAfter
|
||||||
if len(detailRaw) > 0 {
|
if len(detailRaw) > 0 {
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package runtimelogs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const autoSchedulerActor = "auto:scheduler"
|
||||||
|
|
||||||
|
// FileEstimate describes whether a log file would be cleaned by auto policy.
|
||||||
|
type FileEstimate struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
WouldCleanup bool `json:"would_cleanup"`
|
||||||
|
SkipReason string `json:"skip_reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoCleanupDeps wires FS cleanup with audit persistence.
|
||||||
|
type AutoCleanupDeps struct {
|
||||||
|
Service *Service
|
||||||
|
Store store.Backend
|
||||||
|
TenantID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EstimateAutoCleanup lists files that exceed the configured size threshold.
|
||||||
|
func EstimateAutoCleanup(svc *Service, policy AutoPolicy) ([]FileEstimate, error) {
|
||||||
|
if svc == nil || !svc.Available() {
|
||||||
|
return nil, ErrUnavailable
|
||||||
|
}
|
||||||
|
files, err := svc.ListFiles()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]FileEstimate, 0, len(files))
|
||||||
|
for _, f := range files {
|
||||||
|
est := FileEstimate{
|
||||||
|
Filename: f.Name,
|
||||||
|
SizeBytes: f.SizeBytes,
|
||||||
|
}
|
||||||
|
if f.SizeBytes <= policy.MaxFileBytes {
|
||||||
|
est.SkipReason = "under_threshold"
|
||||||
|
} else if f.SizeBytes > MaxCleanupBytes {
|
||||||
|
est.SkipReason = "too_large"
|
||||||
|
} else {
|
||||||
|
est.WouldCleanup = true
|
||||||
|
}
|
||||||
|
out = append(out, est)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunAutoCleanup applies auto policy to eligible files and writes audit rows.
|
||||||
|
func RunAutoCleanup(ctx context.Context, deps AutoCleanupDeps, policy AutoPolicy, dryRun bool, trigger string) (map[string]any, error) {
|
||||||
|
if deps.Service == nil || !deps.Service.Available() {
|
||||||
|
return nil, ErrUnavailable
|
||||||
|
}
|
||||||
|
if deps.Store == nil || deps.TenantID == "" {
|
||||||
|
return nil, fmt.Errorf("runtimelogs: store tenant required for auto cleanup")
|
||||||
|
}
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
estimates, err := EstimateAutoCleanup(deps.Service, policy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
detailBase := map[string]any{
|
||||||
|
"trigger": trigger,
|
||||||
|
"dry_run": dryRun,
|
||||||
|
"max_file_bytes": policy.MaxFileBytes,
|
||||||
|
"mode": policy.Mode,
|
||||||
|
"files_processed": 0,
|
||||||
|
}
|
||||||
|
cleaned := make([]map[string]any, 0)
|
||||||
|
skipped := make([]map[string]any, 0)
|
||||||
|
|
||||||
|
for _, est := range estimates {
|
||||||
|
if !est.WouldCleanup {
|
||||||
|
if est.SkipReason != "" {
|
||||||
|
skipped = append(skipped, map[string]any{
|
||||||
|
"filename": est.Filename,
|
||||||
|
"reason": est.SkipReason,
|
||||||
|
"size": est.SizeBytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if dryRun {
|
||||||
|
cleaned = append(cleaned, map[string]any{
|
||||||
|
"filename": est.Filename,
|
||||||
|
"size": est.SizeBytes,
|
||||||
|
"dry_run": true,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sizeBefore, sizeAfter, err := deps.Service.Cleanup(est.Filename, policy.Mode)
|
||||||
|
if err != nil {
|
||||||
|
skipped = append(skipped, map[string]any{
|
||||||
|
"filename": est.Filename,
|
||||||
|
"reason": err.Error(),
|
||||||
|
"size": est.SizeBytes,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
auditDetail := map[string]any{
|
||||||
|
"trigger": trigger,
|
||||||
|
"mode": policy.Mode,
|
||||||
|
}
|
||||||
|
auditID, err := deps.Store.AppendRuntimeLogCleanupAudit(
|
||||||
|
deps.TenantID, autoSchedulerActor, est.Filename, policy.Mode, sizeBefore, sizeAfter, auditDetail)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("runtimelogs: audit: %w", err)
|
||||||
|
}
|
||||||
|
entry := map[string]any{
|
||||||
|
"filename": est.Filename,
|
||||||
|
"audit_id": auditID,
|
||||||
|
"size_before": sizeBefore,
|
||||||
|
"action": policy.Mode,
|
||||||
|
}
|
||||||
|
if sizeAfter != nil {
|
||||||
|
entry["size_after"] = *sizeAfter
|
||||||
|
}
|
||||||
|
cleaned = append(cleaned, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
detailBase["files_processed"] = len(cleaned)
|
||||||
|
return map[string]any{
|
||||||
|
"dry_run": dryRun,
|
||||||
|
"trigger": trigger,
|
||||||
|
"policy": policySnapshot(policy),
|
||||||
|
"cleaned": cleaned,
|
||||||
|
"skipped": skipped,
|
||||||
|
"cleaned_count": len(cleaned),
|
||||||
|
"skipped_count": len(skipped),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func policySnapshot(p AutoPolicy) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"enabled": p.Enabled,
|
||||||
|
"max_file_bytes": p.MaxFileBytes,
|
||||||
|
"schedule": p.Schedule,
|
||||||
|
"mode": p.Mode,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package runtimelogs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEstimateAndRunAutoCleanup(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
small := filepath.Join(dir, "small.log")
|
||||||
|
large := filepath.Join(dir, "large.log")
|
||||||
|
if err := os.WriteFile(small, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
payload := make([]byte, 2*1024*1024)
|
||||||
|
if err := os.WriteFile(large, payload, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewService(Config{RootDir: dir, ServiceName: ServiceNameAll})
|
||||||
|
policy := AutoPolicy{
|
||||||
|
Enabled: true,
|
||||||
|
MaxFileBytes: 1024 * 1024,
|
||||||
|
Mode: store.RuntimeLogCleanupTruncate,
|
||||||
|
}
|
||||||
|
|
||||||
|
est, err := EstimateAutoCleanup(svc, policy)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(est) != 2 {
|
||||||
|
t.Fatalf("estimates=%d", len(est))
|
||||||
|
}
|
||||||
|
var would int
|
||||||
|
for _, e := range est {
|
||||||
|
if e.WouldCleanup {
|
||||||
|
would++
|
||||||
|
if e.Filename != "large.log" {
|
||||||
|
t.Fatalf("unexpected cleanup target %q", e.Filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if would != 1 {
|
||||||
|
t.Fatalf("would=%d", would)
|
||||||
|
}
|
||||||
|
|
||||||
|
mem := store.NewMemory()
|
||||||
|
tenant := "tenant-a"
|
||||||
|
result, err := RunAutoCleanup(context.Background(), AutoCleanupDeps{
|
||||||
|
Service: svc,
|
||||||
|
Store: mem,
|
||||||
|
TenantID: tenant,
|
||||||
|
}, policy, false, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result["cleaned_count"] != 1 {
|
||||||
|
t.Fatalf("cleaned=%v", result["cleaned_count"])
|
||||||
|
}
|
||||||
|
st, err := os.Stat(large)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.Size() != 0 {
|
||||||
|
t.Fatalf("expected truncated large.log, size=%d", st.Size())
|
||||||
|
}
|
||||||
|
items, _, _, err := mem.ListRuntimeLogCleanupAudit(tenant, "", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("audit rows=%d", len(items))
|
||||||
|
}
|
||||||
|
if items[0].ActorPrefix != autoSchedulerActor {
|
||||||
|
t.Fatalf("actor=%q", items[0].ActorPrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package runtimelogs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// global_settings keys for runtime log auto-cleanup.
|
||||||
|
const (
|
||||||
|
KeyAutoEnabled = "runtime_logs_auto_enabled"
|
||||||
|
KeyMaxFileMB = "runtime_logs_max_file_mb"
|
||||||
|
KeyAutoSchedule = "runtime_logs_auto_schedule"
|
||||||
|
KeyAutoMode = "runtime_logs_auto_mode"
|
||||||
|
DefaultMaxFileMB = 128
|
||||||
|
DefaultSchedule = "0 */6 * * *"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AutoPolicy is tenant KV configuration for scheduled FS log cleanup.
|
||||||
|
type AutoPolicy struct {
|
||||||
|
Enabled bool
|
||||||
|
MaxFileBytes int64
|
||||||
|
Schedule string
|
||||||
|
Mode string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultAutoPolicy returns policy defaults when settings are unset.
|
||||||
|
func DefaultAutoPolicy() AutoPolicy {
|
||||||
|
return AutoPolicy{
|
||||||
|
Enabled: false,
|
||||||
|
MaxFileBytes: int64(DefaultMaxFileMB) * 1024 * 1024,
|
||||||
|
Schedule: DefaultSchedule,
|
||||||
|
Mode: store.RuntimeLogCleanupTruncate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolicyFromSettings reads auto-cleanup policy from tenant global_settings KV.
|
||||||
|
func PolicyFromSettings(settings map[string]any) AutoPolicy {
|
||||||
|
p := DefaultAutoPolicy()
|
||||||
|
if settings == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
if v, ok := settings[KeyAutoEnabled]; ok {
|
||||||
|
p.Enabled = parseBoolSetting(v)
|
||||||
|
}
|
||||||
|
if mb := parseIntSetting(settings[KeyMaxFileMB]); mb > 0 {
|
||||||
|
p.MaxFileBytes = int64(ClampMaxFileMB(mb)) * 1024 * 1024
|
||||||
|
}
|
||||||
|
if s := parseStringSetting(settings[KeyAutoSchedule]); s != "" {
|
||||||
|
p.Schedule = s
|
||||||
|
}
|
||||||
|
if m := parseStringSetting(settings[KeyAutoMode]); store.ValidRuntimeLogCleanupAction(m) {
|
||||||
|
p.Mode = m
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClampMaxFileMB normalizes max file size to 1..512 MiB.
|
||||||
|
func ClampMaxFileMB(mb int) int {
|
||||||
|
if mb <= 0 {
|
||||||
|
return DefaultMaxFileMB
|
||||||
|
}
|
||||||
|
if mb < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if mb > 512 {
|
||||||
|
return 512
|
||||||
|
}
|
||||||
|
return mb
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidAutoSchedule reports whether schedule is a valid 5-field UTC cron expression.
|
||||||
|
func ValidAutoSchedule(schedule string) bool {
|
||||||
|
schedule = strings.TrimSpace(schedule)
|
||||||
|
if schedule == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||||
|
_, err := parser.Parse(schedule)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateRuntimeLogsSettingsPatch normalizes and validates runtime log settings in a PATCH body.
|
||||||
|
// Returns false if any present key is invalid.
|
||||||
|
func ValidateRuntimeLogsSettingsPatch(body map[string]any) bool {
|
||||||
|
if raw, ok := body[KeyAutoEnabled]; ok && raw != nil {
|
||||||
|
v, ok := normalizeBoolSetting(raw)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
body[KeyAutoEnabled] = v
|
||||||
|
}
|
||||||
|
if raw, ok := body[KeyMaxFileMB]; ok && raw != nil {
|
||||||
|
mb, ok := parsePatchInt(raw)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mb = ClampMaxFileMB(mb)
|
||||||
|
body[KeyMaxFileMB] = mb
|
||||||
|
}
|
||||||
|
if raw, ok := body[KeyAutoSchedule]; ok && raw != nil {
|
||||||
|
s := parseStringSetting(raw)
|
||||||
|
if !ValidAutoSchedule(s) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
body[KeyAutoSchedule] = s
|
||||||
|
}
|
||||||
|
if raw, ok := body[KeyAutoMode]; ok && raw != nil {
|
||||||
|
m := parseStringSetting(raw)
|
||||||
|
if !store.ValidRuntimeLogCleanupAction(m) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
body[KeyAutoMode] = m
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBoolSetting(v any) bool {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case bool:
|
||||||
|
return x
|
||||||
|
case string:
|
||||||
|
s := strings.TrimSpace(strings.ToLower(x))
|
||||||
|
return s == "1" || s == "true" || s == "yes"
|
||||||
|
case float64:
|
||||||
|
return x != 0
|
||||||
|
case int:
|
||||||
|
return x != 0
|
||||||
|
case int64:
|
||||||
|
return x != 0
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBoolSetting(v any) (bool, bool) {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case bool:
|
||||||
|
return x, true
|
||||||
|
case string:
|
||||||
|
s := strings.TrimSpace(strings.ToLower(x))
|
||||||
|
switch s {
|
||||||
|
case "1", "true", "yes":
|
||||||
|
return true, true
|
||||||
|
case "0", "false", "no":
|
||||||
|
return false, true
|
||||||
|
default:
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
case float64:
|
||||||
|
if x != 0 && x != 1 {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
return x != 0, true
|
||||||
|
case int:
|
||||||
|
if x != 0 && x != 1 {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
return x != 0, true
|
||||||
|
case int64:
|
||||||
|
if x != 0 && x != 1 {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
return x != 0, true
|
||||||
|
default:
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIntSetting(v any) int {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return int(x)
|
||||||
|
case int:
|
||||||
|
return x
|
||||||
|
case int64:
|
||||||
|
return int(x)
|
||||||
|
case string:
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(x))
|
||||||
|
if err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePatchInt(v any) (int, bool) {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case float64:
|
||||||
|
if x != float64(int(x)) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n := int(x)
|
||||||
|
return n, n >= 1 && n <= 512
|
||||||
|
case int:
|
||||||
|
return x, x >= 1 && x <= 512
|
||||||
|
case int64:
|
||||||
|
n := int(x)
|
||||||
|
return n, n >= 1 && n <= 512
|
||||||
|
case string:
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(x))
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return n, n >= 1 && n <= 512
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseStringSetting(v any) string {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(x)
|
||||||
|
default:
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(fmt.Sprint(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package runtimelogs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPolicyFromSettingsDefaults(t *testing.T) {
|
||||||
|
p := PolicyFromSettings(nil)
|
||||||
|
if p.Enabled {
|
||||||
|
t.Fatal("expected disabled by default")
|
||||||
|
}
|
||||||
|
if p.MaxFileBytes != int64(DefaultMaxFileMB)*1024*1024 {
|
||||||
|
t.Fatalf("max bytes=%d", p.MaxFileBytes)
|
||||||
|
}
|
||||||
|
if p.Schedule != DefaultSchedule {
|
||||||
|
t.Fatalf("schedule=%q", p.Schedule)
|
||||||
|
}
|
||||||
|
if p.Mode != store.RuntimeLogCleanupTruncate {
|
||||||
|
t.Fatalf("mode=%q", p.Mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClampMaxFileMB(t *testing.T) {
|
||||||
|
if ClampMaxFileMB(0) != DefaultMaxFileMB {
|
||||||
|
t.Fatal("zero should default")
|
||||||
|
}
|
||||||
|
if ClampMaxFileMB(999) != 512 {
|
||||||
|
t.Fatal("cap 512")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRuntimeLogsSettingsPatch(t *testing.T) {
|
||||||
|
body := map[string]any{
|
||||||
|
KeyAutoEnabled: true,
|
||||||
|
KeyMaxFileMB: 64,
|
||||||
|
KeyAutoSchedule: "0 3 * * *",
|
||||||
|
KeyAutoMode: "truncate",
|
||||||
|
}
|
||||||
|
if !ValidateRuntimeLogsSettingsPatch(body) {
|
||||||
|
t.Fatal("expected valid patch")
|
||||||
|
}
|
||||||
|
if body[KeyMaxFileMB] != 64 {
|
||||||
|
t.Fatalf("max mb=%v", body[KeyMaxFileMB])
|
||||||
|
}
|
||||||
|
|
||||||
|
bad := map[string]any{KeyAutoSchedule: "not a cron"}
|
||||||
|
if ValidateRuntimeLogsSettingsPatch(bad) {
|
||||||
|
t.Fatal("expected invalid cron")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidAutoSchedule(t *testing.T) {
|
||||||
|
if !ValidAutoSchedule("0 */6 * * *") {
|
||||||
|
t.Fatal("expected valid schedule")
|
||||||
|
}
|
||||||
|
if ValidAutoSchedule("invalid") {
|
||||||
|
t.Fatal("expected invalid schedule")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package runtimelogs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SchedulerDeps configures the runtime log auto-cleanup background scheduler.
|
||||||
|
type SchedulerDeps struct {
|
||||||
|
Service *Service
|
||||||
|
Store store.Backend
|
||||||
|
PolicyTenant string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolvePolicyTenant picks the tenant whose settings drive auto-cleanup.
|
||||||
|
func ResolvePolicyTenant(st store.Backend, explicit string) (string, error) {
|
||||||
|
explicit = strings.TrimSpace(explicit)
|
||||||
|
if explicit != "" {
|
||||||
|
return explicit, nil
|
||||||
|
}
|
||||||
|
if st == nil {
|
||||||
|
return "", fmt.Errorf("runtimelogs: store required")
|
||||||
|
}
|
||||||
|
ids, err := st.ListTenantIDs()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
settings, err := st.ListGlobalSettings(id)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if PolicyFromSettings(settings).Enabled {
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) > 0 {
|
||||||
|
return ids[0], nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("runtimelogs: no tenants configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartAutoCleanupScheduler runs periodic auto-cleanup on evobgp-all when FS is available.
|
||||||
|
func StartAutoCleanupScheduler(ctx context.Context, deps SchedulerDeps, tick time.Duration) {
|
||||||
|
if deps.Service == nil || !deps.Service.Available() {
|
||||||
|
log.Printf("runtimelogs: auto-cleanup scheduler disabled (FS unavailable)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if deps.Store == nil {
|
||||||
|
log.Printf("runtimelogs: auto-cleanup scheduler disabled (no store)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tick <= 0 {
|
||||||
|
tick = 30 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||||
|
var mu sync.Mutex
|
||||||
|
lastFired := map[string]time.Time{}
|
||||||
|
|
||||||
|
t := time.NewTicker(tick)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
tenantID, err := ResolvePolicyTenant(deps.Store, deps.PolicyTenant)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
settings, err := deps.Store.ListGlobalSettings(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
policy := PolicyFromSettings(settings)
|
||||||
|
if !policy.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sched, err := parser.Parse(policy.Schedule)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("runtimelogs: invalid cron %q: %v", policy.Schedule, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
mu.Lock()
|
||||||
|
prev := lastFired[policy.Schedule]
|
||||||
|
if prev.IsZero() {
|
||||||
|
prev = now.Add(-time.Minute)
|
||||||
|
}
|
||||||
|
next := sched.Next(prev)
|
||||||
|
if next.After(now) {
|
||||||
|
mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slot := next.Unix() / 60
|
||||||
|
if lf, ok := lastFired[policy.Schedule]; ok && lf.Unix()/60 == slot {
|
||||||
|
mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lastFired[policy.Schedule] = next
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
_, err = RunAutoCleanup(ctx, AutoCleanupDeps{
|
||||||
|
Service: deps.Service,
|
||||||
|
Store: deps.Store,
|
||||||
|
TenantID: tenantID,
|
||||||
|
}, policy, false, "scheduler")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("runtimelogs: auto-cleanup run failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
log.Printf("runtimelogs: auto-cleanup scheduler started (tick=%s)", tick)
|
||||||
|
}
|
||||||
@@ -84,6 +84,8 @@ type Backend interface {
|
|||||||
// CreateRenderRevision inserts a new config_revision (revID must be unique) with materialized prefixes and preview fragments.
|
// CreateRenderRevision inserts a new config_revision (revID must be unique) with materialized prefixes and preview fragments.
|
||||||
CreateRenderRevision(revisionID, tenantID, moduleID string, parentRevisionID *string, contentHash string, previewFragments map[string]string, prefixes []PrefixRow) error
|
CreateRenderRevision(revisionID, tenantID, moduleID string, parentRevisionID *string, contentHash string, previewFragments map[string]string, prefixes []PrefixRow) error
|
||||||
RevisionDiff(tenantID, aID, bID string) (map[string]any, error)
|
RevisionDiff(tenantID, aID, bID string) (map[string]any, error)
|
||||||
|
EstimateRevisionPrune(tenantID string, cutoff time.Time, retentionMinutes int) (RevisionPruneEstimate, error)
|
||||||
|
PruneRevisionsWithStats(tenantID string, cutoff time.Time) (RevisionPruneResult, error)
|
||||||
PruneRevisionsBefore(tenantID string, cutoff time.Time) (deleted int, err error)
|
PruneRevisionsBefore(tenantID string, cutoff time.Time) (deleted int, err error)
|
||||||
SetLastAppliedRevision(tenantID, speakerID, revisionID string) error
|
SetLastAppliedRevision(tenantID, speakerID, revisionID string) error
|
||||||
PublishRevisionForSpeaker(speakerID, revisionID string) error
|
PublishRevisionForSpeaker(speakerID, revisionID string) error
|
||||||
|
|||||||
@@ -616,62 +616,6 @@ func (m *Memory) RevisionDiff(tenantID, aID, bID string) (map[string]any, error)
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Memory) PruneRevisionsBefore(tenantID string, cutoff time.Time) (int, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
var newest *Revision
|
|
||||||
for _, rev := range m.revisions {
|
|
||||||
if rev.TenantID != tenantID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if newest == nil || rev.CreatedAt.After(newest.CreatedAt) {
|
|
||||||
newest = rev
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if newest == nil {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
protected := map[string]struct{}{
|
|
||||||
newest.ID: {},
|
|
||||||
}
|
|
||||||
for _, sp := range m.speakers {
|
|
||||||
if sp == nil || sp.TenantID != tenantID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if sp.LastAppliedRevisionID != nil && strings.TrimSpace(*sp.LastAppliedRevisionID) != "" {
|
|
||||||
protected[strings.TrimSpace(*sp.LastAppliedRevisionID)] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for speakerID, info := range m.publishedRevision {
|
|
||||||
sp, ok := m.speakers[speakerID]
|
|
||||||
if !ok || sp == nil || sp.TenantID != tenantID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(info.RevisionID) != "" {
|
|
||||||
protected[strings.TrimSpace(info.RevisionID)] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deleted := 0
|
|
||||||
for id, rev := range m.revisions {
|
|
||||||
if rev == nil || rev.TenantID != tenantID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !rev.CreatedAt.Before(cutoff) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, keep := protected[id]; keep {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
delete(m.revisions, id)
|
|
||||||
delete(m.revPrefixes, id)
|
|
||||||
deleted++
|
|
||||||
}
|
|
||||||
return deleted, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Memory) getRevisionLocked(tenantID, revisionID string) (*Revision, error) {
|
func (m *Memory) getRevisionLocked(tenantID, revisionID string) (*Revision, error) {
|
||||||
rev, ok := m.revisions[revisionID]
|
rev, ok := m.revisions[revisionID]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *Memory) prunableRevisionIDsLocked(tenantID string, cutoff time.Time) []string {
|
||||||
|
var newest *Revision
|
||||||
|
for _, rev := range m.revisions {
|
||||||
|
if rev == nil || rev.TenantID != tenantID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if newest == nil || rev.CreatedAt.After(newest.CreatedAt) {
|
||||||
|
newest = rev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if newest == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
protected := map[string]struct{}{newest.ID: {}}
|
||||||
|
for _, sp := range m.speakers {
|
||||||
|
if sp == nil || sp.TenantID != tenantID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sp.LastAppliedRevisionID != nil && strings.TrimSpace(*sp.LastAppliedRevisionID) != "" {
|
||||||
|
protected[strings.TrimSpace(*sp.LastAppliedRevisionID)] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for speakerID, info := range m.publishedRevision {
|
||||||
|
sp, ok := m.speakers[speakerID]
|
||||||
|
if !ok || sp == nil || sp.TenantID != tenantID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(info.RevisionID) != "" {
|
||||||
|
protected[strings.TrimSpace(info.RevisionID)] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var ids []string
|
||||||
|
for id, rev := range m.revisions {
|
||||||
|
if rev == nil || rev.TenantID != tenantID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !rev.CreatedAt.Before(cutoff) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, keep := protected[id]; keep {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func revisionBytesEstimate(rev *Revision, prefixes []PrefixRow) int64 {
|
||||||
|
if rev == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
n += int64(len(rev.ContentHash))
|
||||||
|
for _, v := range rev.PreviewFragments {
|
||||||
|
n += int64(len(v))
|
||||||
|
}
|
||||||
|
for _, pr := range prefixes {
|
||||||
|
n += int64(len(pr.Prefix) + len(pr.Source))
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Memory) EstimateRevisionPrune(tenantID string, cutoff time.Time, retentionMinutes int) (RevisionPruneEstimate, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
ids := m.prunableRevisionIDsLocked(tenantID, cutoff)
|
||||||
|
var bytes int64
|
||||||
|
for _, id := range ids {
|
||||||
|
bytes += revisionBytesEstimate(m.revisions[id], m.revPrefixes[id])
|
||||||
|
}
|
||||||
|
prefixRows := 0
|
||||||
|
for _, id := range ids {
|
||||||
|
prefixRows += len(m.revPrefixes[id])
|
||||||
|
}
|
||||||
|
return RevisionPruneEstimate{
|
||||||
|
RetentionMinutes: retentionMinutes,
|
||||||
|
CutoffAt: cutoff.UTC(),
|
||||||
|
RevisionCount: len(ids),
|
||||||
|
PrefixRowCount: prefixRows,
|
||||||
|
OrphanSnapshotCount: 0,
|
||||||
|
BytesEstimate: bytes,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Memory) PruneRevisionsWithStats(tenantID string, cutoff time.Time) (RevisionPruneResult, error) {
|
||||||
|
est, err := m.EstimateRevisionPrune(tenantID, cutoff, 0)
|
||||||
|
if err != nil {
|
||||||
|
return RevisionPruneResult{}, err
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
ids := m.prunableRevisionIDsLocked(tenantID, cutoff)
|
||||||
|
for _, id := range ids {
|
||||||
|
delete(m.revisions, id)
|
||||||
|
delete(m.revPrefixes, id)
|
||||||
|
}
|
||||||
|
return RevisionPruneResult{
|
||||||
|
DeletedRevisions: len(ids),
|
||||||
|
DeletedPrefixSnapshots: 0,
|
||||||
|
DeletedPrefixRows: est.PrefixRowCount,
|
||||||
|
BytesEstimate: est.BytesEstimate,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Memory) PruneRevisionsBefore(tenantID string, cutoff time.Time) (int, error) {
|
||||||
|
res, err := m.PruneRevisionsWithStats(tenantID, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.DeletedRevisions, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// RevisionPruneEstimate describes revisions and storage that would be removed by prune.
|
||||||
|
type RevisionPruneEstimate struct {
|
||||||
|
RetentionMinutes int `json:"retention_minutes"`
|
||||||
|
CutoffAt time.Time `json:"cutoff_at"`
|
||||||
|
RevisionCount int `json:"revision_count"`
|
||||||
|
PrefixRowCount int `json:"prefix_row_count"`
|
||||||
|
OrphanSnapshotCount int `json:"orphan_snapshot_count"`
|
||||||
|
BytesEstimate int64 `json:"bytes_estimate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevisionPruneResult is the outcome of a revision prune run.
|
||||||
|
type RevisionPruneResult struct {
|
||||||
|
DeletedRevisions int `json:"deleted_revisions"`
|
||||||
|
DeletedPrefixSnapshots int `json:"deleted_prefix_snapshots"`
|
||||||
|
DeletedPrefixRows int `json:"deleted_prefix_rows"`
|
||||||
|
BytesEstimate int64 `json:"bytes_estimate"`
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@
|
|||||||
let files = $state<RuntimeLogFile[]>([]);
|
let files = $state<RuntimeLogFile[]>([]);
|
||||||
|
|
||||||
let auditLoading = $state(false);
|
let auditLoading = $state(false);
|
||||||
|
let auditError = $state<string | null>(null);
|
||||||
let auditItems = $state<RuntimeLogCleanupAudit[]>([]);
|
let auditItems = $state<RuntimeLogCleanupAudit[]>([]);
|
||||||
let auditCursor = $state<string | undefined>(undefined);
|
let auditCursor = $state<string | undefined>(undefined);
|
||||||
let auditHasMore = $state(false);
|
let auditHasMore = $state(false);
|
||||||
@@ -107,6 +108,7 @@
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
sortValue: (r) => r.created_at
|
sortValue: (r) => r.created_at
|
||||||
},
|
},
|
||||||
|
{ id: 'source', label: 'Источник' },
|
||||||
{ id: 'actor', label: 'Actor' },
|
{ id: 'actor', label: 'Actor' },
|
||||||
{ id: 'filename', label: 'Файл', sortable: true, sortValue: (r) => r.filename },
|
{ id: 'filename', label: 'Файл', sortable: true, sortValue: (r) => r.filename },
|
||||||
{ id: 'action', label: 'Действие' },
|
{ id: 'action', label: 'Действие' },
|
||||||
@@ -140,6 +142,7 @@
|
|||||||
|
|
||||||
async function loadAudit(reset = true) {
|
async function loadAudit(reset = true) {
|
||||||
auditLoading = true;
|
auditLoading = true;
|
||||||
|
if (reset) auditError = null;
|
||||||
try {
|
try {
|
||||||
const page = await listRuntimeLogCleanupAudit({
|
const page = await listRuntimeLogCleanupAudit({
|
||||||
limit: 20,
|
limit: 20,
|
||||||
@@ -148,13 +151,22 @@
|
|||||||
auditItems = reset ? (page.items ?? []) : [...auditItems, ...(page.items ?? [])];
|
auditItems = reset ? (page.items ?? []) : [...auditItems, ...(page.items ?? [])];
|
||||||
auditCursor = page.next_cursor;
|
auditCursor = page.next_cursor;
|
||||||
auditHasMore = Boolean(page.has_more && page.next_cursor);
|
auditHasMore = Boolean(page.has_more && page.next_cursor);
|
||||||
|
auditError = null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
auditError = e instanceof Error ? e.message : 'Не удалось загрузить audit';
|
||||||
|
if (reset) auditItems = [];
|
||||||
notifyApiError(e, 'Audit очистки логов');
|
notifyApiError(e, 'Audit очистки логов');
|
||||||
} finally {
|
} finally {
|
||||||
auditLoading = false;
|
auditLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function auditSourceLabel(actor: string): string {
|
||||||
|
if (actor.startsWith('auto:')) return 'Авто';
|
||||||
|
if (actor.startsWith('op:')) return 'Оператор';
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
await Promise.all([loadFiles(), loadAudit(true)]);
|
await Promise.all([loadFiles(), loadAudit(true)]);
|
||||||
}
|
}
|
||||||
@@ -210,12 +222,6 @@
|
|||||||
await loadAudit(true);
|
await loadAudit(true);
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (subTab === 'audit' && auditItems.length === 0 && !auditLoading) {
|
|
||||||
void loadAudit(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
@@ -359,33 +365,47 @@
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="min-w-0 space-y-3 p-4 pt-0">
|
<CardContent class="min-w-0 space-y-3 p-4 pt-0">
|
||||||
<AppDataTable
|
{#if auditError && auditItems.length === 0}
|
||||||
columns={auditColumns}
|
<EmptyState icon={FileText} title="Не удалось загрузить audit" description={auditError}>
|
||||||
rows={auditItems}
|
{#snippet action()}
|
||||||
rowKey={(r) => r.id}
|
<Button variant="outline" size="sm" onclick={() => loadAudit(true)}>
|
||||||
loading={auditLoading && auditItems.length === 0}
|
Повторить
|
||||||
emptyTitle="Записей пока нет"
|
</Button>
|
||||||
emptyDescription="Очистка log-файлов появится здесь после operator DELETE."
|
{/snippet}
|
||||||
>
|
</EmptyState>
|
||||||
{#snippet cell({ row, column })}
|
{:else}
|
||||||
{#if column.id === 'created'}
|
<AppDataTable
|
||||||
<span class="text-sm">{formatDateTime(row.created_at)}</span>
|
columns={auditColumns}
|
||||||
{:else if column.id === 'actor'}
|
rows={auditItems}
|
||||||
<span class="font-mono text-xs">{row.actor_prefix}</span>
|
rowKey={(r) => r.id}
|
||||||
{:else if column.id === 'filename'}
|
loading={auditLoading && auditItems.length === 0}
|
||||||
<span class="font-mono text-xs">{row.filename}</span>
|
emptyTitle="Записей пока нет"
|
||||||
{:else if column.id === 'action'}
|
emptyDescription="Очистка появится после ручного DELETE или автоочистки. Настройки — Параметры → Файловые логи."
|
||||||
<Badge variant={actionBadgeVariant(row.action)}>{row.action}</Badge>
|
>
|
||||||
{:else if column.id === 'sizes'}
|
{#snippet cell({ row, column })}
|
||||||
<span class="text-xs tabular-nums">
|
{#if column.id === 'created'}
|
||||||
{formatBytes(row.size_before)}
|
<span class="text-sm">{formatDateTime(row.created_at)}</span>
|
||||||
{#if row.size_after != null}
|
{:else if column.id === 'source'}
|
||||||
→ {formatBytes(row.size_after)}
|
<Badge variant={row.actor_prefix.startsWith('auto:') ? 'secondary' : 'outline'}>
|
||||||
{/if}
|
{auditSourceLabel(row.actor_prefix)}
|
||||||
</span>
|
</Badge>
|
||||||
{/if}
|
{:else if column.id === 'actor'}
|
||||||
{/snippet}
|
<span class="font-mono text-xs">{row.actor_prefix}</span>
|
||||||
</AppDataTable>
|
{:else if column.id === 'filename'}
|
||||||
|
<span class="font-mono text-xs">{row.filename}</span>
|
||||||
|
{:else if column.id === 'action'}
|
||||||
|
<Badge variant={actionBadgeVariant(row.action)}>{row.action}</Badge>
|
||||||
|
{:else if column.id === 'sizes'}
|
||||||
|
<span class="text-xs tabular-nums">
|
||||||
|
{formatBytes(row.size_before)}
|
||||||
|
{#if row.size_after != null}
|
||||||
|
→ {formatBytes(row.size_after)}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</AppDataTable>
|
||||||
|
{/if}
|
||||||
{#if auditHasMore}
|
{#if auditHasMore}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -2,10 +2,17 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { defaults, superForm } from 'sveltekit-superforms';
|
import { defaults, superForm } from 'sveltekit-superforms';
|
||||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||||
|
import type { AuthSession } from '$lib/api/types.js';
|
||||||
|
import { apiJSON } from '$lib/api/client.js';
|
||||||
import {
|
import {
|
||||||
emptyRevisionSettingsForm,
|
emptyRevisionSettingsForm,
|
||||||
revisionSettingsSchema
|
revisionSettingsSchema
|
||||||
} from '$lib/settings/revision-settings.schema.js';
|
} from '$lib/settings/revision-settings.schema.js';
|
||||||
|
import {
|
||||||
|
fetchRevisionPruneEstimate,
|
||||||
|
pruneRevisionsNow,
|
||||||
|
type RevisionPruneEstimate
|
||||||
|
} from '$lib/settings/revision-prune-api.js';
|
||||||
import {
|
import {
|
||||||
buildPayloadFromFormFields,
|
buildPayloadFromFormFields,
|
||||||
loadSettings,
|
loadSettings,
|
||||||
@@ -13,6 +20,7 @@
|
|||||||
patchSettings
|
patchSettings
|
||||||
} from '$lib/settings/settings-api.js';
|
} from '$lib/settings/settings-api.js';
|
||||||
import { REVISION_SETTING_KEYS } from '$lib/settings/settings-known-keys.js';
|
import { REVISION_SETTING_KEYS } from '$lib/settings/settings-known-keys.js';
|
||||||
|
import { formatBytes } from '$lib/monitoring/postgres.js';
|
||||||
import { Button } from '$lib/ui/core/button/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -23,12 +31,18 @@
|
|||||||
} from '$lib/ui/core/card/index.js';
|
} from '$lib/ui/core/card/index.js';
|
||||||
import { Input } from '$lib/ui/core/input/index.js';
|
import { Input } from '$lib/ui/core/input/index.js';
|
||||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
||||||
|
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import Save from '@lucide/svelte/icons/save';
|
import Save from '@lucide/svelte/icons/save';
|
||||||
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
|
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
let pruning = $state(false);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
let session = $state<AuthSession | null>(null);
|
||||||
|
let estimateLoading = $state(false);
|
||||||
|
let estimate = $state<RevisionPruneEstimate | null>(null);
|
||||||
|
|
||||||
const { form, errors, reset, validateForm } = superForm(
|
const { form, errors, reset, validateForm } = superForm(
|
||||||
defaults(emptyRevisionSettingsForm(), zod4(revisionSettingsSchema)),
|
defaults(emptyRevisionSettingsForm(), zod4(revisionSettingsSchema)),
|
||||||
@@ -39,13 +53,60 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
let hasValidationErrors = $derived(Boolean($errors.revision_retention_minutes?.length));
|
const isOperator = $derived(session?.role === 'operator');
|
||||||
|
const hasValidationErrors = $derived(Boolean($errors.revision_retention_minutes?.length));
|
||||||
|
|
||||||
let canSave = $derived.by(() => {
|
const parsedRetentionMinutes = $derived.by(() => {
|
||||||
|
const s = String($form.revision_retention_minutes ?? '').trim();
|
||||||
|
if (s === '' || !/^\d+$/.test(s)) return null;
|
||||||
|
const n = Number(s);
|
||||||
|
if (!Number.isInteger(n) || n < 15 || n > 43200) return null;
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canSave = $derived.by(() => {
|
||||||
if (loading || saving || hasValidationErrors || !loaded) return false;
|
if (loading || saving || hasValidationErrors || !loaded) return false;
|
||||||
return String($form.revision_retention_minutes ?? '').trim() !== '';
|
return String($form.revision_retention_minutes ?? '').trim() !== '';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const canPruneNow = $derived.by(() => {
|
||||||
|
if (!isOperator || !loaded || pruning || saving || parsedRetentionMinutes === null)
|
||||||
|
return false;
|
||||||
|
return (estimate?.revision_count ?? 0) > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadSession() {
|
||||||
|
try {
|
||||||
|
session = await apiJSON<AuthSession>('/v1/auth/session');
|
||||||
|
} catch {
|
||||||
|
session = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshEstimate(minutes: number) {
|
||||||
|
estimateLoading = true;
|
||||||
|
try {
|
||||||
|
estimate = await fetchRevisionPruneEstimate(minutes);
|
||||||
|
} catch (e) {
|
||||||
|
estimate = null;
|
||||||
|
notifyApiError(e, 'Не удалось рассчитать оценку очистки');
|
||||||
|
} finally {
|
||||||
|
estimateLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const minutes = parsedRetentionMinutes;
|
||||||
|
if (!loaded || minutes === null) {
|
||||||
|
estimate = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const handle = setTimeout(() => {
|
||||||
|
void refreshEstimate(minutes);
|
||||||
|
}, 400);
|
||||||
|
return () => clearTimeout(handle);
|
||||||
|
});
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
@@ -89,7 +150,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestPruneNow() {
|
||||||
|
const minutes = parsedRetentionMinutes;
|
||||||
|
if (minutes === null) return;
|
||||||
|
let est: RevisionPruneEstimate;
|
||||||
|
try {
|
||||||
|
est = await fetchRevisionPruneEstimate(minutes);
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
estimate = est;
|
||||||
|
if (est.revision_count === 0) {
|
||||||
|
notify.info('Нет ревизий для удаления по выбранному retention');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void confirm({
|
||||||
|
title: 'Очистить старые ревизии?',
|
||||||
|
description: `Будет удалено ${est.revision_count} ревизий. Ориентировочно освободится ~${formatBytes(est.bytes_estimate)}. Действие необратимо.`,
|
||||||
|
confirmLabel: 'Очистить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
pruning = true;
|
||||||
|
try {
|
||||||
|
const res = await pruneRevisionsNow(minutes);
|
||||||
|
notify.success(
|
||||||
|
`Удалено ревизий: ${res.deleted_revisions}, освобождено ~${formatBytes(res.bytes_estimate)}`
|
||||||
|
);
|
||||||
|
await refreshEstimate(minutes);
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
pruning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
void loadSession();
|
||||||
void load();
|
void load();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -98,7 +198,8 @@
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Хранение ревизий</CardTitle>
|
<CardTitle>Хранение ревизий</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Автоматическая очистка старых ревизий. Последняя раскатанная ревизия не удаляется.
|
Автоматическая очистка старых ревизий. Последняя ревизия и раскатанные на спикерах не
|
||||||
|
удаляются.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
@@ -123,16 +224,54 @@
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
{#if parsedRetentionMinutes !== null}
|
||||||
|
<div class="space-y-1 rounded-md border bg-muted/30 p-4 text-sm">
|
||||||
|
<p class="font-medium">Оценка очистки по введённому retention</p>
|
||||||
|
{#if estimateLoading}
|
||||||
|
<p class="text-muted-foreground">Расчёт…</p>
|
||||||
|
{:else if estimate}
|
||||||
|
<p>
|
||||||
|
Будет удалено ревизий: <strong>{estimate.revision_count}</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Освободится ориентировочно: <strong>~{formatBytes(estimate.bytes_estimate)}</strong>
|
||||||
|
</p>
|
||||||
|
{#if estimate.prefix_row_count > 0}
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Строк префиксов в снимках: {estimate.prefix_row_count}
|
||||||
|
{#if estimate.orphan_snapshot_count > 0}
|
||||||
|
· снимков: {estimate.orphan_snapshot_count}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted-foreground">Оценка недоступна</p>
|
||||||
|
{/if}
|
||||||
|
<p class="pt-1 text-xs text-muted-foreground">
|
||||||
|
Учитываются те же правила, что при автоочистке: последняя ревизия и раскатанные на
|
||||||
|
спикерах не удаляются.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if hasValidationErrors}
|
{#if hasValidationErrors}
|
||||||
<p class="text-sm text-destructive">
|
<p class="text-sm text-destructive">
|
||||||
Есть ошибки в полях. Исправьте их, чтобы сохранить изменения.
|
Есть ошибки в полях. Исправьте их, чтобы сохранить изменения.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Button onclick={save} disabled={!canSave}>
|
<div class="flex flex-wrap gap-2">
|
||||||
<Save />
|
<Button onclick={save} disabled={!canSave}>
|
||||||
{saving ? 'Сохранение…' : 'Применить параметры ревизий'}
|
<Save />
|
||||||
</Button>
|
{saving ? 'Сохранение…' : 'Применить параметры ревизий'}
|
||||||
|
</Button>
|
||||||
|
{#if isOperator}
|
||||||
|
<Button variant="destructive" disabled={!canPruneNow} onclick={requestPruneNow}>
|
||||||
|
<Trash2 />
|
||||||
|
{pruning ? 'Очистка…' : 'Очистить сейчас'}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { defaults, superForm } from 'sveltekit-superforms';
|
||||||
|
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||||
|
import type { AuthSession } from '$lib/api/types.js';
|
||||||
|
import { apiJSON } from '$lib/api/client.js';
|
||||||
|
import {
|
||||||
|
emptyRuntimeLogsSettingsForm,
|
||||||
|
runtimeLogsSettingsSchema
|
||||||
|
} from '$lib/settings/runtime-logs-settings.schema.js';
|
||||||
|
import {
|
||||||
|
buildPayloadFromFormFields,
|
||||||
|
loadSettings,
|
||||||
|
partitionSettings,
|
||||||
|
patchSettings
|
||||||
|
} from '$lib/settings/settings-api.js';
|
||||||
|
import { RUNTIME_LOGS_SETTING_KEYS } from '$lib/settings/settings-known-keys.js';
|
||||||
|
import {
|
||||||
|
fetchRuntimeLogAutoEstimate,
|
||||||
|
runRuntimeLogAutoCleanup,
|
||||||
|
type RuntimeLogAutoEstimate
|
||||||
|
} from '$lib/runtime-logs/runtime-logs-auto-api.js';
|
||||||
|
import { isRuntimeLogsUnavailable } from '$lib/runtime-logs/runtime-logs-api.js';
|
||||||
|
import { formatBytes } from '$lib/monitoring/postgres.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle
|
||||||
|
} from '$lib/ui/core/card/index.js';
|
||||||
|
import { Input } from '$lib/ui/core/input/index.js';
|
||||||
|
import { Switch } from '$lib/ui/core/switch/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 { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||||
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
import Save from '@lucide/svelte/icons/save';
|
||||||
|
import Play from '@lucide/svelte/icons/play';
|
||||||
|
|
||||||
|
let loading = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
|
let running = $state(false);
|
||||||
|
let loaded = $state(false);
|
||||||
|
let session = $state<AuthSession | null>(null);
|
||||||
|
let estimateLoading = $state(false);
|
||||||
|
let estimate = $state<RuntimeLogAutoEstimate | null>(null);
|
||||||
|
let fsUnavailable = $state(false);
|
||||||
|
|
||||||
|
const { form, errors, reset, validateForm } = superForm(
|
||||||
|
defaults(emptyRuntimeLogsSettingsForm(), zod4(runtimeLogsSettingsSchema)),
|
||||||
|
{
|
||||||
|
validators: zod4(runtimeLogsSettingsSchema),
|
||||||
|
SPA: true,
|
||||||
|
dataType: 'json'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const isOperator = $derived(session?.role === 'operator');
|
||||||
|
const autoEnabled = $derived($form.runtime_logs_auto_enabled === 'true');
|
||||||
|
const hasValidationErrors = $derived(
|
||||||
|
Boolean(
|
||||||
|
$errors.runtime_logs_max_file_mb?.length ||
|
||||||
|
$errors.runtime_logs_auto_schedule?.length ||
|
||||||
|
$errors.runtime_logs_auto_mode?.length
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsedMaxMb = $derived.by(() => {
|
||||||
|
const s = String($form.runtime_logs_max_file_mb ?? '').trim();
|
||||||
|
if (s === '' || !/^\d+$/.test(s)) return null;
|
||||||
|
const n = Number(s);
|
||||||
|
if (!Number.isInteger(n) || n < 1 || n > 512) return null;
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canSave = $derived.by(() => {
|
||||||
|
if (loading || saving || hasValidationErrors || !loaded || !isOperator) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canRunNow = $derived.by(() => {
|
||||||
|
if (!isOperator || !loaded || running || saving || parsedMaxMb === null || fsUnavailable)
|
||||||
|
return false;
|
||||||
|
return (estimate?.would_count ?? 0) > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadSession() {
|
||||||
|
try {
|
||||||
|
session = await apiJSON<AuthSession>('/v1/auth/session');
|
||||||
|
} catch {
|
||||||
|
session = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshEstimate() {
|
||||||
|
if (fsUnavailable || parsedMaxMb === null) {
|
||||||
|
estimate = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
estimateLoading = true;
|
||||||
|
try {
|
||||||
|
estimate = await fetchRuntimeLogAutoEstimate();
|
||||||
|
fsUnavailable = false;
|
||||||
|
} catch (e) {
|
||||||
|
estimate = null;
|
||||||
|
if (isRuntimeLogsUnavailable(e)) {
|
||||||
|
fsUnavailable = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifyApiError(e, 'Оценка автоочистки');
|
||||||
|
} finally {
|
||||||
|
estimateLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!loaded || parsedMaxMb === null) {
|
||||||
|
estimate = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const handle = setTimeout(() => {
|
||||||
|
void refreshEstimate();
|
||||||
|
}, 400);
|
||||||
|
return () => clearTimeout(handle);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const settings = await loadSettings();
|
||||||
|
const { partitioned } = partitionSettings(settings);
|
||||||
|
reset({ data: partitioned.runtimeLogs });
|
||||||
|
loaded = true;
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const validation = await validateForm({ update: true });
|
||||||
|
if (!validation.valid) {
|
||||||
|
notify.error('Исправьте ошибки в полях настроек');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = buildPayloadFromFormFields(
|
||||||
|
RUNTIME_LOGS_SETTING_KEYS,
|
||||||
|
$form as Record<string, string>,
|
||||||
|
$errors as Partial<Record<string, string[]>>
|
||||||
|
);
|
||||||
|
if ($form.runtime_logs_auto_mode === 'truncate' || $form.runtime_logs_auto_mode === 'delete') {
|
||||||
|
payload.runtime_logs_auto_mode = $form.runtime_logs_auto_mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
await patchSettings(payload);
|
||||||
|
notify.success('Параметры автоочистки логов сохранены');
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestRunNow() {
|
||||||
|
if (parsedMaxMb === null) return;
|
||||||
|
let est: RuntimeLogAutoEstimate;
|
||||||
|
try {
|
||||||
|
est = await fetchRuntimeLogAutoEstimate();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
estimate = est;
|
||||||
|
const count = est.would_count ?? 0;
|
||||||
|
if (count === 0) {
|
||||||
|
notify.info('Нет файлов выше порога для очистки');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mode = $form.runtime_logs_auto_mode === 'delete' ? 'удаление' : 'truncate';
|
||||||
|
void confirm({
|
||||||
|
title: 'Запустить автоочистку сейчас?',
|
||||||
|
description: `Будет затронуто файлов: ${count}. Режим: ${mode}. Записи появятся в Monitoring → Файловые логи → Audit.`,
|
||||||
|
confirmLabel: 'Запустить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
running = true;
|
||||||
|
try {
|
||||||
|
const res = await runRuntimeLogAutoCleanup(false);
|
||||||
|
notify.success(`Очищено файлов: ${res.cleaned_count ?? 0}`);
|
||||||
|
await refreshEstimate();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void loadSession();
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Файловые логи (runtime-logs)</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Автоочистка <code class="text-xs">*.log</code> на диске evobgp-all (sidecar
|
||||||
|
stack-runtime-logs). Требуется volume <code class="text-xs">EVOBGP_RUNTIME_LOGS_DIR</code>.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
{#if loading && !loaded}
|
||||||
|
<p class="text-sm text-muted-foreground">Загрузка…</p>
|
||||||
|
{:else if !loaded}
|
||||||
|
<Button variant="outline" onclick={load}>Загрузить настройки</Button>
|
||||||
|
{:else}
|
||||||
|
<FormField
|
||||||
|
id="runtime-logs-auto-enabled"
|
||||||
|
label="Автоочистка по расписанию"
|
||||||
|
description="Scheduler в evobgp-all (UTC cron ниже)."
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
id="runtime-logs-auto-enabled"
|
||||||
|
checked={autoEnabled}
|
||||||
|
disabled={!isOperator}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
$form.runtime_logs_auto_enabled = v ? 'true' : 'false';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="runtime-logs-max-mb"
|
||||||
|
label="Порог размера файла, MiB"
|
||||||
|
error={$errors.runtime_logs_max_file_mb?.[0]}
|
||||||
|
description="Очищать файлы строго больше порога (1–512 MiB)."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="runtime-logs-max-mb"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="512"
|
||||||
|
bind:value={$form.runtime_logs_max_file_mb}
|
||||||
|
disabled={!isOperator}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="runtime-logs-schedule"
|
||||||
|
label="Расписание (UTC cron)"
|
||||||
|
error={$errors.runtime_logs_auto_schedule?.[0]}
|
||||||
|
description="5 полей: минута час день месяц день_недели. По умолчанию каждые 6 часов."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="runtime-logs-schedule"
|
||||||
|
bind:value={$form.runtime_logs_auto_schedule}
|
||||||
|
placeholder="0 */6 * * *"
|
||||||
|
disabled={!isOperator}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField id="runtime-logs-mode" label="Режим очистки">
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={$form.runtime_logs_auto_mode || 'truncate'}
|
||||||
|
disabled={!isOperator}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v === 'truncate' || v === 'delete') $form.runtime_logs_auto_mode = v;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="runtime-logs-mode" class="w-full max-w-xs">
|
||||||
|
{$form.runtime_logs_auto_mode === 'delete'
|
||||||
|
? 'delete — удалить файл'
|
||||||
|
: 'truncate — обнулить'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="truncate">truncate — обнулить</SelectItem>
|
||||||
|
<SelectItem value="delete">delete — удалить файл</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{#if fsUnavailable}
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
FS API недоступен (не evobgp-all или нет volume). Оценка и «Запустить сейчас» недоступны;
|
||||||
|
настройки сохраняются для будущего прогона scheduler.
|
||||||
|
</p>
|
||||||
|
{:else if parsedMaxMb !== null}
|
||||||
|
<div class="space-y-1 rounded-md border bg-muted/30 p-4 text-sm">
|
||||||
|
<p class="font-medium">Оценка по текущему порогу</p>
|
||||||
|
{#if estimateLoading}
|
||||||
|
<p class="text-muted-foreground">Расчёт…</p>
|
||||||
|
{:else if estimate}
|
||||||
|
<p>
|
||||||
|
Файлов к очистке: <strong>{estimate.would_count ?? 0}</strong>
|
||||||
|
</p>
|
||||||
|
{#if estimate.items?.length}
|
||||||
|
<ul class="mt-2 space-y-1 text-xs text-muted-foreground">
|
||||||
|
{#each estimate.items.filter((i) => i.would_cleanup) as item (item.filename)}
|
||||||
|
<li class="font-mono">
|
||||||
|
{item.filename} · {formatBytes(item.size_bytes)}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted-foreground">Оценка недоступна</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if hasValidationErrors}
|
||||||
|
<p class="text-sm text-destructive">Исправьте ошибки в полях перед сохранением.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#if isOperator}
|
||||||
|
<Button onclick={save} disabled={!canSave}>
|
||||||
|
<Save />
|
||||||
|
{saving ? 'Сохранение…' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" disabled={!canRunNow} onclick={requestRunNow}>
|
||||||
|
<Play />
|
||||||
|
{running ? 'Запуск…' : 'Запустить сейчас'}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
@@ -8,13 +8,14 @@
|
|||||||
import TenantBirdSettingsCard from '$lib/components/tenant-settings/TenantBirdSettingsCard.svelte';
|
import TenantBirdSettingsCard from '$lib/components/tenant-settings/TenantBirdSettingsCard.svelte';
|
||||||
import TenantRevisionSettingsCard from '$lib/components/tenant-settings/TenantRevisionSettingsCard.svelte';
|
import TenantRevisionSettingsCard from '$lib/components/tenant-settings/TenantRevisionSettingsCard.svelte';
|
||||||
import TenantAdditionalSettingsCard from '$lib/components/tenant-settings/TenantAdditionalSettingsCard.svelte';
|
import TenantAdditionalSettingsCard from '$lib/components/tenant-settings/TenantAdditionalSettingsCard.svelte';
|
||||||
|
import TenantRuntimeLogsSettingsCard from '$lib/components/tenant-settings/TenantRuntimeLogsSettingsCard.svelte';
|
||||||
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||||
import Info from '@lucide/svelte/icons/info';
|
import Info from '@lucide/svelte/icons/info';
|
||||||
|
|
||||||
type TenantSettingsTab = 'bird' | 'revision' | 'additional';
|
type TenantSettingsTab = 'bird' | 'revision' | 'runtime-logs' | 'additional';
|
||||||
|
|
||||||
function parseTenantSettingsTab(value: string | null): TenantSettingsTab {
|
function parseTenantSettingsTab(value: string | null): TenantSettingsTab {
|
||||||
if (value === 'revision' || value === 'additional') return value;
|
if (value === 'revision' || value === 'runtime-logs' || value === 'additional') return value;
|
||||||
return 'bird';
|
return 'bird';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@
|
|||||||
<TabsList class="inline-flex min-w-max">
|
<TabsList class="inline-flex min-w-max">
|
||||||
<TabsTrigger value="bird">BIRD</TabsTrigger>
|
<TabsTrigger value="bird">BIRD</TabsTrigger>
|
||||||
<TabsTrigger value="revision">Ревизии</TabsTrigger>
|
<TabsTrigger value="revision">Ревизии</TabsTrigger>
|
||||||
|
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger>
|
||||||
<TabsTrigger value="additional">Дополнительно</TabsTrigger>
|
<TabsTrigger value="additional">Дополнительно</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,6 +79,10 @@
|
|||||||
<TenantRevisionSettingsCard />
|
<TenantRevisionSettingsCard />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="runtime-logs" class="mt-4">
|
||||||
|
<TenantRuntimeLogsSettingsCard />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="additional" class="mt-4">
|
<TabsContent value="additional" class="mt-4">
|
||||||
<TenantAdditionalSettingsCard />
|
<TenantAdditionalSettingsCard />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -33,6 +33,24 @@ export const maintenancePolicyPresets: MaintenancePolicyPreset[] = [
|
|||||||
dry_run_enabled: true
|
dry_run_enabled: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'runtime_log_cleanup_audit_retention',
|
||||||
|
label: 'Runtime log cleanup audit — 90d',
|
||||||
|
description:
|
||||||
|
'Удаляет записи runtime_log_cleanup_audit старше 90 дней (ручная и автоочистка FS).',
|
||||||
|
tableHint: 'runtime_log_cleanup_audit',
|
||||||
|
form: {
|
||||||
|
name: 'Runtime log cleanup audit (90d)',
|
||||||
|
table_name: 'runtime_log_cleanup_audit',
|
||||||
|
condition: 'true',
|
||||||
|
retention_period_sec: String(90 * DAY_SEC),
|
||||||
|
max_rows: '5000',
|
||||||
|
vacuum_strategy: 'none',
|
||||||
|
schedule: '0 4 * * *',
|
||||||
|
enabled: true,
|
||||||
|
dry_run_enabled: true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'postgres_maintenance_audit_retention',
|
id: 'postgres_maintenance_audit_retention',
|
||||||
label: 'Maintenance audit — 30d',
|
label: 'Maintenance audit — 30d',
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
||||||
|
|
||||||
|
export type RuntimeLogAutoEstimateItem = {
|
||||||
|
filename: string;
|
||||||
|
size_bytes: number;
|
||||||
|
would_cleanup: boolean;
|
||||||
|
skip_reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeLogAutoEstimate = {
|
||||||
|
policy?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
max_file_bytes?: number;
|
||||||
|
schedule?: string;
|
||||||
|
mode?: 'truncate' | 'delete';
|
||||||
|
};
|
||||||
|
items?: RuntimeLogAutoEstimateItem[];
|
||||||
|
would_count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeLogAutoRunResult = {
|
||||||
|
dry_run?: boolean;
|
||||||
|
trigger?: string;
|
||||||
|
cleaned_count?: number;
|
||||||
|
skipped_count?: number;
|
||||||
|
cleaned?: unknown[];
|
||||||
|
skipped?: unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchRuntimeLogAutoEstimate(): Promise<RuntimeLogAutoEstimate> {
|
||||||
|
return apiJSON<RuntimeLogAutoEstimate>('/v1/runtime-logs/auto-estimate');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runRuntimeLogAutoCleanup(dryRun = false): Promise<RuntimeLogAutoRunResult> {
|
||||||
|
const q = dryRun ? '?dry_run=true' : '';
|
||||||
|
return apiMutate<RuntimeLogAutoRunResult>(`/v1/runtime-logs/auto-run${q}`, 'POST');
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
||||||
|
|
||||||
|
export type RevisionPruneEstimate = {
|
||||||
|
retention_minutes: number;
|
||||||
|
cutoff_at: string;
|
||||||
|
revision_count: number;
|
||||||
|
prefix_row_count: number;
|
||||||
|
orphan_snapshot_count: number;
|
||||||
|
bytes_estimate: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RevisionPruneResult = {
|
||||||
|
deleted_revisions: number;
|
||||||
|
deleted_prefix_snapshots: number;
|
||||||
|
deleted_prefix_rows: number;
|
||||||
|
bytes_estimate: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchRevisionPruneEstimate(
|
||||||
|
retentionMinutes: number
|
||||||
|
): Promise<RevisionPruneEstimate> {
|
||||||
|
const q = new URLSearchParams({ retention_minutes: String(retentionMinutes) });
|
||||||
|
return apiJSON<RevisionPruneEstimate>(`/v1/revisions/prune-estimate?${q}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pruneRevisionsNow(retentionMinutes: number): Promise<RevisionPruneResult> {
|
||||||
|
return apiMutate<RevisionPruneResult>('/v1/revisions/prune', 'POST', {
|
||||||
|
retention_minutes: retentionMinutes
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
function boolInput(val: unknown): string {
|
||||||
|
if (val === undefined || val === null) return 'false';
|
||||||
|
if (typeof val === 'boolean') return val ? 'true' : 'false';
|
||||||
|
if (typeof val === 'number') return val !== 0 ? 'true' : 'false';
|
||||||
|
return String(val).trim().toLowerCase() === 'true' || String(val).trim() === '1'
|
||||||
|
? 'true'
|
||||||
|
: 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringInput(val: unknown): string {
|
||||||
|
if (val === undefined || val === null) return '';
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeLogsAutoEnabled = z.preprocess(boolInput, z.enum(['true', 'false']));
|
||||||
|
|
||||||
|
const runtimeLogsMaxFileMb = z.preprocess(
|
||||||
|
stringInput,
|
||||||
|
z.string().refine(
|
||||||
|
(v) => {
|
||||||
|
const s = v.trim();
|
||||||
|
if (s === '') return true;
|
||||||
|
const n = Number(s);
|
||||||
|
return /^\d+$/.test(s) && Number.isInteger(n) && n >= 1 && n <= 512;
|
||||||
|
},
|
||||||
|
{ message: 'Порог должен быть целым числом от 1 до 512 MiB' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const runtimeLogsAutoSchedule = z.preprocess(
|
||||||
|
stringInput,
|
||||||
|
z.string().refine(
|
||||||
|
(v) => {
|
||||||
|
const s = v.trim();
|
||||||
|
if (s === '') return true;
|
||||||
|
const parts = s.split(/\s+/);
|
||||||
|
return parts.length === 5;
|
||||||
|
},
|
||||||
|
{ message: 'Cron: 5 полей (минута час день месяц день_недели), UTC' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const runtimeLogsAutoMode = z.preprocess(
|
||||||
|
stringInput,
|
||||||
|
z.enum(['truncate', 'delete', '']).refine((v) => v === '' || v === 'truncate' || v === 'delete', {
|
||||||
|
message: 'Режим: truncate или delete'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const runtimeLogsSettingsSchema = z.object({
|
||||||
|
runtime_logs_auto_enabled: runtimeLogsAutoEnabled,
|
||||||
|
runtime_logs_max_file_mb: runtimeLogsMaxFileMb,
|
||||||
|
runtime_logs_auto_schedule: runtimeLogsAutoSchedule,
|
||||||
|
runtime_logs_auto_mode: runtimeLogsAutoMode
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RuntimeLogsSettingsForm = z.infer<typeof runtimeLogsSettingsSchema>;
|
||||||
|
|
||||||
|
export const emptyRuntimeLogsSettingsForm = (): RuntimeLogsSettingsForm => ({
|
||||||
|
runtime_logs_auto_enabled: 'false',
|
||||||
|
runtime_logs_max_file_mb: '128',
|
||||||
|
runtime_logs_auto_schedule: '0 */6 * * *',
|
||||||
|
runtime_logs_auto_mode: 'truncate'
|
||||||
|
});
|
||||||
@@ -5,13 +5,20 @@ import {
|
|||||||
emptyRevisionSettingsForm,
|
emptyRevisionSettingsForm,
|
||||||
type RevisionSettingsForm
|
type RevisionSettingsForm
|
||||||
} from './revision-settings.schema.js';
|
} from './revision-settings.schema.js';
|
||||||
|
import {
|
||||||
|
emptyRuntimeLogsSettingsForm,
|
||||||
|
type RuntimeLogsSettingsForm
|
||||||
|
} from './runtime-logs-settings.schema.js';
|
||||||
import {
|
import {
|
||||||
BIRD_SETTING_KEYS,
|
BIRD_SETTING_KEYS,
|
||||||
|
BOOLEAN_SETTING_KEYS,
|
||||||
KNOWN_SETTING_KEYS,
|
KNOWN_SETTING_KEYS,
|
||||||
NUMERIC_SETTING_KEYS,
|
NUMERIC_SETTING_KEYS,
|
||||||
|
RUNTIME_LOGS_SETTING_KEYS,
|
||||||
type BirdSettingKey,
|
type BirdSettingKey,
|
||||||
type KnownSettingKey,
|
type KnownSettingKey,
|
||||||
type RevisionSettingKey
|
type RevisionSettingKey,
|
||||||
|
type RuntimeLogsSettingKey
|
||||||
} from './settings-known-keys.js';
|
} from './settings-known-keys.js';
|
||||||
|
|
||||||
export type AdditionalSettingEntry = { id: number; key: string; value: string };
|
export type AdditionalSettingEntry = { id: number; key: string; value: string };
|
||||||
@@ -19,6 +26,7 @@ export type AdditionalSettingEntry = { id: number; key: string; value: string };
|
|||||||
export type PartitionedSettings = {
|
export type PartitionedSettings = {
|
||||||
bird: BirdSettingsForm;
|
bird: BirdSettingsForm;
|
||||||
revision: RevisionSettingsForm;
|
revision: RevisionSettingsForm;
|
||||||
|
runtimeLogs: RuntimeLogsSettingsForm;
|
||||||
additional: AdditionalSettingEntry[];
|
additional: AdditionalSettingEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,6 +46,7 @@ export function partitionSettings(
|
|||||||
): { partitioned: PartitionedSettings; nextId: number } {
|
): { partitioned: PartitionedSettings; nextId: number } {
|
||||||
const bird = emptyBirdSettingsForm();
|
const bird = emptyBirdSettingsForm();
|
||||||
const revision = emptyRevisionSettingsForm();
|
const revision = emptyRevisionSettingsForm();
|
||||||
|
const runtimeLogs = emptyRuntimeLogsSettingsForm();
|
||||||
const additional: AdditionalSettingEntry[] = [];
|
const additional: AdditionalSettingEntry[] = [];
|
||||||
let idCounter = nextId;
|
let idCounter = nextId;
|
||||||
|
|
||||||
@@ -46,6 +55,18 @@ export function partitionSettings(
|
|||||||
bird[key as BirdSettingKey] = parseKnownValue(key as KnownSettingKey, value);
|
bird[key as BirdSettingKey] = parseKnownValue(key as KnownSettingKey, value);
|
||||||
} else if (key === 'revision_retention_minutes') {
|
} else if (key === 'revision_retention_minutes') {
|
||||||
revision.revision_retention_minutes = parseKnownValue(key as RevisionSettingKey, value);
|
revision.revision_retention_minutes = parseKnownValue(key as RevisionSettingKey, value);
|
||||||
|
} else if ((RUNTIME_LOGS_SETTING_KEYS as readonly string[]).includes(key)) {
|
||||||
|
const rk = key as RuntimeLogsSettingKey;
|
||||||
|
if (rk === 'runtime_logs_auto_enabled') {
|
||||||
|
runtimeLogs.runtime_logs_auto_enabled =
|
||||||
|
value === true || value === 1 || value === 'true' || value === '1' ? 'true' : 'false';
|
||||||
|
} else if (rk === 'runtime_logs_auto_mode') {
|
||||||
|
const m = String(value ?? '').trim();
|
||||||
|
runtimeLogs.runtime_logs_auto_mode =
|
||||||
|
m === 'delete' ? 'delete' : m === 'truncate' ? 'truncate' : '';
|
||||||
|
} else {
|
||||||
|
runtimeLogs[rk] = parseKnownValue(key as KnownSettingKey, value);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
additional.push({
|
additional.push({
|
||||||
id: idCounter++,
|
id: idCounter++,
|
||||||
@@ -56,7 +77,7 @@ export function partitionSettings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
partitioned: { bird, revision, additional },
|
partitioned: { bird, revision, runtimeLogs, additional },
|
||||||
nextId: idCounter
|
nextId: idCounter
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -65,7 +86,9 @@ export async function loadSettings(): Promise<AppSettings> {
|
|||||||
return apiJSON<AppSettings>('/v1/settings');
|
return apiJSON<AppSettings>('/v1/settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function patchSettings(payload: Record<string, string | number>): Promise<void> {
|
export async function patchSettings(
|
||||||
|
payload: Record<string, string | number | boolean>
|
||||||
|
): Promise<void> {
|
||||||
await apiMutate('/v1/settings', 'PATCH', payload);
|
await apiMutate('/v1/settings', 'PATCH', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,11 +96,16 @@ export function buildPayloadFromFormFields(
|
|||||||
keys: readonly KnownSettingKey[],
|
keys: readonly KnownSettingKey[],
|
||||||
form: Record<string, string>,
|
form: Record<string, string>,
|
||||||
errors: Partial<Record<string, string[]>>
|
errors: Partial<Record<string, string[]>>
|
||||||
): Record<string, string | number> {
|
): Record<string, string | number | boolean> {
|
||||||
const payload: Record<string, string | number> = {};
|
const payload: Record<string, string | number | boolean> = {};
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const value = String(form[key] ?? '').trim();
|
const value = String(form[key] ?? '').trim();
|
||||||
if (!value || errors[key]?.length) continue;
|
if (errors[key]?.length) continue;
|
||||||
|
if (BOOLEAN_SETTING_KEYS.has(key)) {
|
||||||
|
payload[key] = value === 'true';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!value) continue;
|
||||||
if (NUMERIC_SETTING_KEYS.has(key)) payload[key] = Number(value);
|
if (NUMERIC_SETTING_KEYS.has(key)) payload[key] = Number(value);
|
||||||
else payload[key] = value;
|
else payload[key] = value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,28 @@ export const BIRD_SETTING_KEYS = [
|
|||||||
|
|
||||||
export const REVISION_SETTING_KEYS = ['revision_retention_minutes'] as const;
|
export const REVISION_SETTING_KEYS = ['revision_retention_minutes'] as const;
|
||||||
|
|
||||||
export const KNOWN_SETTING_KEYS = [...BIRD_SETTING_KEYS, ...REVISION_SETTING_KEYS] as const;
|
export const RUNTIME_LOGS_SETTING_KEYS = [
|
||||||
|
'runtime_logs_auto_enabled',
|
||||||
|
'runtime_logs_max_file_mb',
|
||||||
|
'runtime_logs_auto_schedule',
|
||||||
|
'runtime_logs_auto_mode'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const KNOWN_SETTING_KEYS = [
|
||||||
|
...BIRD_SETTING_KEYS,
|
||||||
|
...REVISION_SETTING_KEYS,
|
||||||
|
...RUNTIME_LOGS_SETTING_KEYS
|
||||||
|
] as const;
|
||||||
|
|
||||||
export type BirdSettingKey = (typeof BIRD_SETTING_KEYS)[number];
|
export type BirdSettingKey = (typeof BIRD_SETTING_KEYS)[number];
|
||||||
export type RevisionSettingKey = (typeof REVISION_SETTING_KEYS)[number];
|
export type RevisionSettingKey = (typeof REVISION_SETTING_KEYS)[number];
|
||||||
|
export type RuntimeLogsSettingKey = (typeof RUNTIME_LOGS_SETTING_KEYS)[number];
|
||||||
export type KnownSettingKey = (typeof KNOWN_SETTING_KEYS)[number];
|
export type KnownSettingKey = (typeof KNOWN_SETTING_KEYS)[number];
|
||||||
|
|
||||||
export const NUMERIC_SETTING_KEYS = new Set<KnownSettingKey>([
|
export const NUMERIC_SETTING_KEYS = new Set<KnownSettingKey>([
|
||||||
'bird_local_asn',
|
'bird_local_asn',
|
||||||
'revision_retention_minutes'
|
'revision_retention_minutes',
|
||||||
|
'runtime_logs_max_file_mb'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const BOOLEAN_SETTING_KEYS = new Set<KnownSettingKey>(['runtime_logs_auto_enabled']);
|
||||||
Reference in new issue
Block a user