@verdeect/kit 0.0.0-stage → 1.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +138 -0
- package/README.md +804 -2
- package/alerts/verdeect-kit.rules.yml +133 -0
- package/dist/cli/commands/crypto.d.ts +9 -0
- package/dist/cli/commands/crypto.js +87 -0
- package/dist/cli/commands/db.d.ts +11 -0
- package/dist/cli/commands/db.js +151 -0
- package/dist/cli/commands/openapi.d.ts +9 -0
- package/dist/cli/commands/openapi.js +144 -0
- package/dist/cli/commands/rabbitmq.d.ts +11 -0
- package/dist/cli/commands/rabbitmq.js +124 -0
- package/dist/cli/commands/redis.d.ts +11 -0
- package/dist/cli/commands/redis.js +119 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +31 -0
- package/dist/config/cli-schemas.d.ts +29 -0
- package/dist/config/cli-schemas.js +15 -0
- package/dist/config/define-env.d.ts +21 -0
- package/dist/config/define-env.js +43 -0
- package/dist/config/fragments.d.ts +105 -0
- package/dist/config/fragments.js +86 -0
- package/dist/config/helpers.d.ts +26 -0
- package/dist/config/helpers.js +62 -0
- package/dist/config/index.d.ts +7 -0
- package/dist/config/index.js +7 -0
- package/dist/config/kit-config.module.d.ts +20 -0
- package/dist/config/kit-config.module.js +23 -0
- package/dist/config/parse-env.d.ts +14 -0
- package/dist/config/parse-env.js +28 -0
- package/dist/crypto/cipher.d.ts +61 -0
- package/dist/crypto/cipher.js +136 -0
- package/dist/crypto/index.d.ts +2 -0
- package/dist/crypto/index.js +2 -0
- package/dist/database/batches.d.ts +42 -0
- package/dist/database/batches.js +49 -0
- package/dist/database/db.d.ts +93 -0
- package/dist/database/db.js +234 -0
- package/dist/database/if-newer.d.ts +9 -0
- package/dist/database/if-newer.js +15 -0
- package/dist/database/index.d.ts +17 -0
- package/dist/database/index.js +13 -0
- package/dist/database/kit-database.module.d.ts +47 -0
- package/dist/database/kit-database.module.js +181 -0
- package/dist/database/migrations.d.ts +42 -0
- package/dist/database/migrations.js +142 -0
- package/dist/database/pg.d.ts +20 -0
- package/dist/database/pg.js +19 -0
- package/dist/database/prepare-database.d.ts +39 -0
- package/dist/database/prepare-database.js +75 -0
- package/dist/database/run-exclusive.d.ts +19 -0
- package/dist/database/run-exclusive.js +62 -0
- package/dist/database/tenant.d.ts +34 -0
- package/dist/database/tenant.js +28 -0
- package/dist/http/api/api.decorator.d.ts +18 -0
- package/dist/http/api/api.decorator.js +9 -0
- package/dist/http/api/openapi-document.d.ts +40 -0
- package/dist/http/api/openapi-document.js +452 -0
- package/dist/http/bootstrap-service.d.ts +33 -0
- package/dist/http/bootstrap-service.js +107 -0
- package/dist/http/create-http-app.d.ts +38 -0
- package/dist/http/create-http-app.js +137 -0
- package/dist/http/errors.d.ts +118 -0
- package/dist/http/errors.js +94 -0
- package/dist/http/index.d.ts +12 -0
- package/dist/http/index.js +8 -0
- package/dist/http/kit-exception.filter.d.ts +18 -0
- package/dist/http/kit-exception.filter.js +151 -0
- package/dist/http/kit-http-core.module.d.ts +19 -0
- package/dist/http/kit-http-core.module.js +122 -0
- package/dist/http/pagination.d.ts +30 -0
- package/dist/http/pagination.js +73 -0
- package/dist/http/zod-validation.pipe.d.ts +15 -0
- package/dist/http/zod-validation.pipe.js +47 -0
- package/dist/idempotency/idempotency.interceptor.d.ts +55 -0
- package/dist/idempotency/idempotency.interceptor.js +317 -0
- package/dist/idempotency/idempotent.decorator.d.ts +7 -0
- package/dist/idempotency/idempotent.decorator.js +16 -0
- package/dist/idempotency/index.d.ts +7 -0
- package/dist/idempotency/index.js +6 -0
- package/dist/idempotency/kit-idempotency.module.d.ts +47 -0
- package/dist/idempotency/kit-idempotency.module.js +173 -0
- package/dist/idempotency/peer.d.ts +1 -0
- package/dist/idempotency/peer.js +4 -0
- package/dist/idempotency/table.d.ts +203 -0
- package/dist/idempotency/table.js +86 -0
- package/dist/internal/cleanup-result.d.ts +7 -0
- package/dist/internal/cleanup-result.js +12 -0
- package/dist/internal/ids.d.ts +13 -0
- package/dist/internal/ids.js +41 -0
- package/dist/internal/metadata.d.ts +12 -0
- package/dist/internal/metadata.js +3 -0
- package/dist/internal/outbox-policy.d.ts +8 -0
- package/dist/internal/outbox-policy.js +10 -0
- package/dist/internal/peer.d.ts +16 -0
- package/dist/internal/peer.js +56 -0
- package/dist/internal/pg-errors.d.ts +21 -0
- package/dist/internal/pg-errors.js +115 -0
- package/dist/internal/preparsed-env.d.ts +4 -0
- package/dist/internal/preparsed-env.js +14 -0
- package/dist/internal/rabbitmq-definitions.d.ts +85 -0
- package/dist/internal/rabbitmq-definitions.js +73 -0
- package/dist/internal/redis-acl.d.ts +21 -0
- package/dist/internal/redis-acl.js +30 -0
- package/dist/internal/service-name.d.ts +4 -0
- package/dist/internal/service-name.js +10 -0
- package/dist/internal/sql-identifier.d.ts +5 -0
- package/dist/internal/sql-identifier.js +12 -0
- package/dist/internal/tokens.d.ts +10 -0
- package/dist/internal/tokens.js +12 -0
- package/dist/internal/tx-context.d.ts +5 -0
- package/dist/internal/tx-context.js +9 -0
- package/dist/messaging/cleanup.d.ts +49 -0
- package/dist/messaging/cleanup.js +129 -0
- package/dist/messaging/config.d.ts +43 -0
- package/dist/messaging/config.js +8 -0
- package/dist/messaging/define-event.d.ts +29 -0
- package/dist/messaging/define-event.js +38 -0
- package/dist/messaging/envelope.d.ts +66 -0
- package/dist/messaging/envelope.js +49 -0
- package/dist/messaging/errors.d.ts +45 -0
- package/dist/messaging/errors.js +63 -0
- package/dist/messaging/inbox.d.ts +4 -0
- package/dist/messaging/inbox.js +9 -0
- package/dist/messaging/index.d.ts +13 -0
- package/dist/messaging/index.js +10 -0
- package/dist/messaging/kit-messaging.module.d.ts +39 -0
- package/dist/messaging/kit-messaging.module.js +309 -0
- package/dist/messaging/outbox-relay.d.ts +67 -0
- package/dist/messaging/outbox-relay.js +260 -0
- package/dist/messaging/outbox-store.d.ts +75 -0
- package/dist/messaging/outbox-store.js +126 -0
- package/dist/messaging/peer.d.ts +1 -0
- package/dist/messaging/peer.js +5 -0
- package/dist/messaging/rabbit-connection.d.ts +144 -0
- package/dist/messaging/rabbit-connection.js +472 -0
- package/dist/messaging/redelivery.d.ts +6 -0
- package/dist/messaging/redelivery.js +17 -0
- package/dist/messaging/subscriber.d.ts +102 -0
- package/dist/messaging/subscriber.js +412 -0
- package/dist/messaging/tables.d.ts +315 -0
- package/dist/messaging/tables.js +72 -0
- package/dist/messaging/topology.d.ts +56 -0
- package/dist/messaging/topology.js +56 -0
- package/dist/observability/fail-startup.d.ts +14 -0
- package/dist/observability/fail-startup.js +51 -0
- package/dist/observability/health-registry.d.ts +34 -0
- package/dist/observability/health-registry.js +81 -0
- package/dist/observability/index.d.ts +16 -0
- package/dist/observability/index.js +10 -0
- package/dist/observability/lifecycle.d.ts +52 -0
- package/dist/observability/lifecycle.js +151 -0
- package/dist/observability/logger-options.d.ts +54 -0
- package/dist/observability/logger-options.js +91 -0
- package/dist/observability/metrics-server.d.ts +33 -0
- package/dist/observability/metrics-server.js +108 -0
- package/dist/observability/metrics.d.ts +30 -0
- package/dist/observability/metrics.js +53 -0
- package/dist/observability/redaction.d.ts +23 -0
- package/dist/observability/redaction.js +65 -0
- package/dist/observability/sanitize-error.d.ts +47 -0
- package/dist/observability/sanitize-error.js +194 -0
- package/dist/observability/sentry.d.ts +64 -0
- package/dist/observability/sentry.js +233 -0
- package/dist/redis/channels.d.ts +34 -0
- package/dist/redis/channels.js +128 -0
- package/dist/redis/client.d.ts +42 -0
- package/dist/redis/client.js +85 -0
- package/dist/redis/index.d.ts +7 -0
- package/dist/redis/index.js +7 -0
- package/dist/redis/kit-redis.module.d.ts +18 -0
- package/dist/redis/kit-redis.module.js +131 -0
- package/dist/redis/peer.d.ts +1 -0
- package/dist/redis/peer.js +4 -0
- package/dist/redis/rate-limiter.d.ts +49 -0
- package/dist/redis/rate-limiter.js +139 -0
- package/dist/testing/count-queries.d.ts +30 -0
- package/dist/testing/count-queries.js +58 -0
- package/dist/testing/create-test-app.d.ts +26 -0
- package/dist/testing/create-test-app.js +32 -0
- package/dist/testing/expect-matches-api.d.ts +8 -0
- package/dist/testing/expect-matches-api.js +49 -0
- package/dist/testing/expect-problem.d.ts +24 -0
- package/dist/testing/expect-problem.js +23 -0
- package/dist/testing/images.d.ts +10 -0
- package/dist/testing/images.js +14 -0
- package/dist/testing/index.d.ts +23 -0
- package/dist/testing/index.js +17 -0
- package/dist/testing/launch-command.d.ts +52 -0
- package/dist/testing/launch-command.js +510 -0
- package/dist/testing/outbox-coverage.d.ts +26 -0
- package/dist/testing/outbox-coverage.js +98 -0
- package/dist/testing/rabbitmq-admin.d.ts +81 -0
- package/dist/testing/rabbitmq-admin.js +146 -0
- package/dist/testing/redis-worker.d.ts +10 -0
- package/dist/testing/redis-worker.js +15 -0
- package/dist/testing/repo-conventions.d.ts +10 -0
- package/dist/testing/repo-conventions.js +524 -0
- package/dist/testing/repo-files.d.ts +113 -0
- package/dist/testing/repo-files.js +401 -0
- package/dist/testing/scratch-database.d.ts +19 -0
- package/dist/testing/scratch-database.js +75 -0
- package/dist/testing/start-test-stack.d.ts +85 -0
- package/dist/testing/start-test-stack.js +342 -0
- package/dist/testing/tcp-proxy.d.ts +40 -0
- package/dist/testing/tcp-proxy.js +164 -0
- package/dist/testing/tenant-isolation.d.ts +31 -0
- package/dist/testing/tenant-isolation.js +205 -0
- package/dist/testing/test-events.d.ts +37 -0
- package/dist/testing/test-events.js +150 -0
- package/dist/testing/timeout-scale.d.ts +4 -0
- package/dist/testing/timeout-scale.js +17 -0
- package/dist/testing/timing-guard.d.ts +22 -0
- package/dist/testing/timing-guard.js +54 -0
- package/dist/testing/worker-scope.d.ts +31 -0
- package/dist/testing/worker-scope.js +68 -0
- package/package.json +177 -4
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Шаблон правил vmalert для метрик @verdeect/kit — prd.md §3.2 (alertRulesTemplate), AC-2.11, UC-13.
|
|
2
|
+
# Формат правил Prometheus (совместим с vmalert). Путь к файлу в установленном пакете — экспорт alertRulesTemplate
|
|
3
|
+
# из @verdeect/kit/observability.
|
|
4
|
+
#
|
|
5
|
+
# Порог: у каждого правила есть порог по умолчанию и переопределение по метке service. Чтобы задать свой порог,
|
|
6
|
+
# добавьте в файл правил сервиса recording-правило verdeect_kit_threshold:<метрика> с меткой service, например:
|
|
7
|
+
#
|
|
8
|
+
# - record: verdeect_kit_threshold:outbox_lag_seconds
|
|
9
|
+
# expr: vector(30)
|
|
10
|
+
# labels:
|
|
11
|
+
# service: platform
|
|
12
|
+
#
|
|
13
|
+
# Имена порогов: verdeect_kit_threshold:outbox_pending, :outbox_lag_seconds, :events_consumed (result="dlq"),
|
|
14
|
+
# :db_pool_wait_timeouts, :rate_limit_unavailable, :cleanup_runs. Правая часть каждого правила агрегирована max by (service),
|
|
15
|
+
# чтобы сравнение on (service) group_left () не давало many-to-many.
|
|
16
|
+
groups:
|
|
17
|
+
- name: verdeect-kit
|
|
18
|
+
rules:
|
|
19
|
+
- alert: VerdeectOutboxLag
|
|
20
|
+
# Порог по умолчанию — 5 с за 1 минуту.
|
|
21
|
+
expr: |
|
|
22
|
+
max by (service) (verdeect_outbox_lag_seconds)
|
|
23
|
+
> on (service) group_left ()
|
|
24
|
+
(
|
|
25
|
+
max by (service) (verdeect_kit_threshold:outbox_lag_seconds)
|
|
26
|
+
or on (service) (max by (service) (verdeect_outbox_lag_seconds) * 0 + 5)
|
|
27
|
+
)
|
|
28
|
+
for: 1m
|
|
29
|
+
labels:
|
|
30
|
+
severity: warning
|
|
31
|
+
annotations:
|
|
32
|
+
summary: 'Задержка outbox {{ $labels.service }} выше порога'
|
|
33
|
+
description: >-
|
|
34
|
+
Самая старая неотправленная строка outbox сервиса {{ $labels.service }} ждёт дольше порога
|
|
35
|
+
(verdeect_outbox_lag_seconds, по умолчанию 5 с) в течение минуты. Проверьте relay в журнале
|
|
36
|
+
сервиса и доступность брокера.
|
|
37
|
+
|
|
38
|
+
- alert: VerdeectOutboxPending
|
|
39
|
+
# Порог по умолчанию — 1000 строк за 5 минут.
|
|
40
|
+
expr: |
|
|
41
|
+
max by (service) (verdeect_outbox_pending)
|
|
42
|
+
> on (service) group_left ()
|
|
43
|
+
(
|
|
44
|
+
max by (service) (verdeect_kit_threshold:outbox_pending)
|
|
45
|
+
or on (service) (max by (service) (verdeect_outbox_pending) * 0 + 1000)
|
|
46
|
+
)
|
|
47
|
+
for: 5m
|
|
48
|
+
labels:
|
|
49
|
+
severity: warning
|
|
50
|
+
annotations:
|
|
51
|
+
summary: 'Очередь outbox {{ $labels.service }} выше порога'
|
|
52
|
+
description: >-
|
|
53
|
+
Неотправленных строк outbox сервиса {{ $labels.service }} больше порога (verdeect_outbox_pending,
|
|
54
|
+
по умолчанию 1000) в течение 5 минут. Проверьте relay и брокер.
|
|
55
|
+
|
|
56
|
+
- alert: VerdeectEventsDlq
|
|
57
|
+
# Порог по умолчанию — 0: любое событие в DLQ за 15 минут.
|
|
58
|
+
expr: |
|
|
59
|
+
sum by (service, type) (increase(verdeect_events_consumed_total{result="dlq"}[15m]))
|
|
60
|
+
> on (service) group_left ()
|
|
61
|
+
(
|
|
62
|
+
max by (service) (verdeect_kit_threshold:events_consumed)
|
|
63
|
+
or on (service)
|
|
64
|
+
(
|
|
65
|
+
max by (service) (
|
|
66
|
+
sum by (service, type) (increase(verdeect_events_consumed_total{result="dlq"}[15m]))
|
|
67
|
+
) * 0
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
labels:
|
|
71
|
+
severity: warning
|
|
72
|
+
annotations:
|
|
73
|
+
summary: 'События {{ $labels.type }} сервиса {{ $labels.service }} уходят в DLQ'
|
|
74
|
+
description: >-
|
|
75
|
+
За 15 минут события {{ $labels.type }} сервиса {{ $labels.service }} попали в DLQ
|
|
76
|
+
(verdeect_events_consumed_total, result="dlq"). Разберите сообщения очереди <svc>.dlq по
|
|
77
|
+
заголовку x-dlq-reason и журналу подписчика; у очередей реплики (<svc>.replica.*) сообщение не
|
|
78
|
+
сохраняется — смотрите лог и GlitchTip.
|
|
79
|
+
|
|
80
|
+
- alert: VerdeectDbPoolWaitTimeouts
|
|
81
|
+
# Порог по умолчанию — 0: любой срок ожидания пула за 5 минут.
|
|
82
|
+
expr: |
|
|
83
|
+
sum by (service) (increase(verdeect_db_pool_wait_timeouts_total[5m]))
|
|
84
|
+
> on (service) group_left ()
|
|
85
|
+
(
|
|
86
|
+
max by (service) (verdeect_kit_threshold:db_pool_wait_timeouts)
|
|
87
|
+
or on (service)
|
|
88
|
+
(max by (service) (sum by (service) (increase(verdeect_db_pool_wait_timeouts_total[5m]))) * 0)
|
|
89
|
+
)
|
|
90
|
+
labels:
|
|
91
|
+
severity: warning
|
|
92
|
+
annotations:
|
|
93
|
+
summary: 'Пул БД {{ $labels.service }} исчерпан'
|
|
94
|
+
description: >-
|
|
95
|
+
За 5 минут запросы сервиса {{ $labels.service }} не дождались клиента пула БД
|
|
96
|
+
(verdeect_db_pool_wait_timeouts_total). Проверьте DB_POOL_MAX, долгие запросы и нагрузку.
|
|
97
|
+
|
|
98
|
+
- alert: VerdeectRateLimitUnavailable
|
|
99
|
+
# Порог по умолчанию — 0: любая проверка лимита без Redis за 5 минут.
|
|
100
|
+
expr: |
|
|
101
|
+
sum by (service) (increase(verdeect_rate_limit_unavailable_total[5m]))
|
|
102
|
+
> on (service) group_left ()
|
|
103
|
+
(
|
|
104
|
+
max by (service) (verdeect_kit_threshold:rate_limit_unavailable)
|
|
105
|
+
or on (service)
|
|
106
|
+
(max by (service) (sum by (service) (increase(verdeect_rate_limit_unavailable_total[5m]))) * 0)
|
|
107
|
+
)
|
|
108
|
+
labels:
|
|
109
|
+
severity: warning
|
|
110
|
+
annotations:
|
|
111
|
+
summary: 'Лимитер {{ $labels.service }} работает без Redis'
|
|
112
|
+
description: >-
|
|
113
|
+
За 5 минут сервис {{ $labels.service }} пропускал запросы без проверки лимита: Redis недоступен
|
|
114
|
+
(verdeect_rate_limit_unavailable_total). Проверьте доступность Redis.
|
|
115
|
+
|
|
116
|
+
- alert: VerdeectCleanupFailing
|
|
117
|
+
# Порог по умолчанию — 0: любой неудачный или неполный проход очистки за 2 часа.
|
|
118
|
+
expr: |
|
|
119
|
+
sum by (service, table, result) (increase(verdeect_cleanup_runs_total{result=~"error|partial"}[2h]))
|
|
120
|
+
> on (service) group_left ()
|
|
121
|
+
(
|
|
122
|
+
max by (service) (verdeect_kit_threshold:cleanup_runs)
|
|
123
|
+
or on (service) (max by (service) (verdeect_cleanup_runs_total) * 0)
|
|
124
|
+
)
|
|
125
|
+
labels:
|
|
126
|
+
severity: warning
|
|
127
|
+
annotations:
|
|
128
|
+
summary: 'Очистка {{ $labels.table }} сервиса {{ $labels.service }}: {{ $labels.result }}'
|
|
129
|
+
description: >-
|
|
130
|
+
За 2 часа проход очистки таблицы {{ $labels.table }} сервиса {{ $labels.service }} завершился с итогом
|
|
131
|
+
{{ $labels.result }} (verdeect_cleanup_runs_total). error — проход упал: ошибка в журнале сервиса
|
|
132
|
+
(«очистка … : ошибка»). partial — проход упёрся в предел пачек, записи старше срока остались: очистка
|
|
133
|
+
не успевает за потоком записей.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Окружение команды. */
|
|
2
|
+
export interface CryptoCommandContext {
|
|
3
|
+
/** Запись результата в stdout. */
|
|
4
|
+
readonly write: (text: string) => void;
|
|
5
|
+
/** Переменные окружения (по умолчанию `process.env`). */
|
|
6
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
7
|
+
}
|
|
8
|
+
/** Обработчик команды `crypto`: 0 — секрет в stdout `{"secret":"…"}`, 1 — ошибка (JSON `{error}` в stdout). */
|
|
9
|
+
export declare function cryptoCommand(args: readonly string[], context?: CryptoCommandContext): Promise<number>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Команда verdeect-kit crypto generate-secret [--bytes N] (prd.md §3.6, UC-10, AC-7.5): секрет base64url для
|
|
2
|
+
// ENCRYPTION_KEYS. Образец — commands/rabbitmq.ts. Без pg, ioredis, amqplib: только crypto динамическим import().
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { pino } from 'pino';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
const defaultContext = {
|
|
7
|
+
write: (text) => void process.stdout.write(text),
|
|
8
|
+
env: process.env,
|
|
9
|
+
};
|
|
10
|
+
const DEFAULT_BYTES = 32;
|
|
11
|
+
const BYTES_USAGE = '--bytes — целое не меньше 32';
|
|
12
|
+
/** Ошибка команды: JSON в stdout и код 1. */
|
|
13
|
+
class CommandError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
extra;
|
|
16
|
+
constructor(code, message, extra = {}) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.extra = extra;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function createLogger(env) {
|
|
23
|
+
const [{ envBase }, { createLoggerOptions }] = await Promise.all([
|
|
24
|
+
import('../../config/fragments.js'),
|
|
25
|
+
import('../../observability/logger-options.js'),
|
|
26
|
+
]);
|
|
27
|
+
const parsed = z
|
|
28
|
+
.object({ LOG_LEVEL: envBase.LOG_LEVEL, REPLICA_ID: envBase.REPLICA_ID })
|
|
29
|
+
.safeParse(env);
|
|
30
|
+
if (!parsed.success) {
|
|
31
|
+
const keys = [...new Set(parsed.error.issues.map((issue) => issue.path.join('.')))];
|
|
32
|
+
throw new CommandError('INVALID_ENVIRONMENT', 'Неверное окружение', { keys });
|
|
33
|
+
}
|
|
34
|
+
return pino(...createLoggerOptions({ service: 'verdeect-kit', entry: 'cli', env: parsed.data })
|
|
35
|
+
.pinoHttp);
|
|
36
|
+
}
|
|
37
|
+
/** Разбор `generate-secret [--bytes N]` → число байт. */
|
|
38
|
+
function bytesOf(args) {
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = parseArgs({
|
|
42
|
+
args: [...args],
|
|
43
|
+
strict: true,
|
|
44
|
+
allowPositionals: true,
|
|
45
|
+
options: { bytes: { type: 'string' } },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
throw new CommandError('USAGE', error instanceof Error ? error.message : String(error));
|
|
50
|
+
}
|
|
51
|
+
const [sub, ...rest] = parsed.positionals;
|
|
52
|
+
if (sub !== 'generate-secret' || rest.length > 0) {
|
|
53
|
+
throw new CommandError('USAGE', 'подкоманда crypto: generate-secret [--bytes N]');
|
|
54
|
+
}
|
|
55
|
+
const raw = parsed.values.bytes;
|
|
56
|
+
if (raw === undefined) {
|
|
57
|
+
return DEFAULT_BYTES;
|
|
58
|
+
}
|
|
59
|
+
if (!/^\d+$/.test(raw) || Number(raw) < DEFAULT_BYTES) {
|
|
60
|
+
throw new CommandError('USAGE', BYTES_USAGE);
|
|
61
|
+
}
|
|
62
|
+
return Number(raw);
|
|
63
|
+
}
|
|
64
|
+
function failureOf(error) {
|
|
65
|
+
if (error instanceof CommandError) {
|
|
66
|
+
return { code: error.code, message: error.message, ...error.extra };
|
|
67
|
+
}
|
|
68
|
+
return { code: 'CRYPTO_COMMAND_FAILED', message: 'команда crypto завершилась ошибкой' };
|
|
69
|
+
}
|
|
70
|
+
/** Обработчик команды `crypto`: 0 — секрет в stdout `{"secret":"…"}`, 1 — ошибка (JSON `{error}` в stdout). */
|
|
71
|
+
export async function cryptoCommand(args, context = defaultContext) {
|
|
72
|
+
try {
|
|
73
|
+
const bytes = bytesOf(args);
|
|
74
|
+
const logger = await createLogger(context.env);
|
|
75
|
+
// Только число байт: секрет в лог не попадает.
|
|
76
|
+
logger.debug({ command: 'crypto', sub: 'generate-secret', bytes }, `crypto generate-secret: ${bytes} байт`);
|
|
77
|
+
const { generateSecret } = await import('../../crypto/index.js');
|
|
78
|
+
context.write(`${JSON.stringify({ secret: generateSecret(bytes) })}\n`);
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
const failure = failureOf(error);
|
|
83
|
+
process.stderr.write(`${JSON.stringify({ level: 'error', ...failure })}\n`);
|
|
84
|
+
context.write(`${JSON.stringify({ error: failure })}\n`);
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Окружение команды. */
|
|
2
|
+
export interface DbCommandContext {
|
|
3
|
+
/** Рабочий каталог (от него — путь `--folder`). */
|
|
4
|
+
readonly cwd: string;
|
|
5
|
+
/** Запись строки результата в stdout. */
|
|
6
|
+
readonly write: (line: string) => void;
|
|
7
|
+
/** Переменные окружения (по умолчанию `process.env`). */
|
|
8
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
9
|
+
}
|
|
10
|
+
/** Обработчик команды `db`: 0 — подкоманда выполнена, 1 — ошибка (JSON `{error}` в stdout). */
|
|
11
|
+
export declare function dbCommand(args: readonly string[], context?: DbCommandContext): Promise<number>;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Команда verdeect-kit db prepare|migrate|move-journal (prd.md §3.3 v1.14, AC-4.17, UC-12). Эталон — O run-cli.ts
|
|
2
|
+
// (db:prepare, migrate). Подпути пакета — динамическим import() (ARCHITECTURE «CLI»); окружение — только prepareEnvSchema /
|
|
3
|
+
// migrateEnvSchema и LOG_LEVEL, REPLICA_ID логгера. Текст ошибок pg (имя роли, хост) не выводится.
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
import { pino } from 'pino';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
const defaultContext = {
|
|
9
|
+
cwd: process.cwd(),
|
|
10
|
+
write: (line) => void process.stdout.write(line),
|
|
11
|
+
env: process.env,
|
|
12
|
+
};
|
|
13
|
+
const SUBCOMMANDS_MESSAGE = 'подкоманда db: prepare, migrate, move-journal';
|
|
14
|
+
/** Ошибка команды: JSON в stdout и код 1. */
|
|
15
|
+
class CommandError extends Error {
|
|
16
|
+
code;
|
|
17
|
+
extra;
|
|
18
|
+
constructor(code, message, extra = {}) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.extra = extra;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Только имена ключей: значения окружения не выводятся. */
|
|
25
|
+
function invalidEnvironment(error) {
|
|
26
|
+
const keys = [...new Set(error.issues.map((issue) => issue.path.map(String).join('.')))];
|
|
27
|
+
return new CommandError('INVALID_ENVIRONMENT', 'Неверное окружение', { keys });
|
|
28
|
+
}
|
|
29
|
+
function parseEnv(schema, env) {
|
|
30
|
+
const parsed = schema.safeParse(env);
|
|
31
|
+
if (!parsed.success) {
|
|
32
|
+
throw invalidEnvironment(parsed.error);
|
|
33
|
+
}
|
|
34
|
+
return parsed.data;
|
|
35
|
+
}
|
|
36
|
+
async function createLogger(env) {
|
|
37
|
+
const [{ envBase }, { createLoggerOptions }] = await Promise.all([
|
|
38
|
+
import('../../config/fragments.js'),
|
|
39
|
+
import('../../observability/logger-options.js'),
|
|
40
|
+
]);
|
|
41
|
+
const parsed = parseEnv(z.object({ LOG_LEVEL: envBase.LOG_LEVEL, REPLICA_ID: envBase.REPLICA_ID }), env);
|
|
42
|
+
return pino(...createLoggerOptions({ service: 'verdeect-kit', entry: 'cli', env: parsed }).pinoHttp);
|
|
43
|
+
}
|
|
44
|
+
/** Опции подкоманды; неизвестная опция или опция без значения — `USAGE`. */
|
|
45
|
+
function parseOptions(args, names) {
|
|
46
|
+
const options = Object.fromEntries(names.map((name) => [name, { type: 'string' }]));
|
|
47
|
+
try {
|
|
48
|
+
return parseArgs({ args: [...args], options, strict: true }).values;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
throw new CommandError('USAGE', error instanceof Error ? error.message : String(error));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function required(values, names) {
|
|
55
|
+
for (const name of names) {
|
|
56
|
+
if (values[name] === undefined) {
|
|
57
|
+
throw new CommandError('USAGE', `нужна опция --${name}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function prepare(args, context, logger) {
|
|
62
|
+
const values = parseOptions(args, ['service']);
|
|
63
|
+
if (values.service === undefined) {
|
|
64
|
+
throw new CommandError('USAGE', 'нужна опция --service <svc>');
|
|
65
|
+
}
|
|
66
|
+
const { SERVICE_NAME_PATTERN } = await import('../../internal/service-name.js');
|
|
67
|
+
if (!SERVICE_NAME_PATTERN.test(values.service)) {
|
|
68
|
+
throw new CommandError('USAGE', '--service: имя сервиса вне формата ^[a-z][a-z0-9-]{1,31}$');
|
|
69
|
+
}
|
|
70
|
+
const { prepareEnvSchema } = await import('../../config/cli-schemas.js');
|
|
71
|
+
const env = parseEnv(prepareEnvSchema, context.env);
|
|
72
|
+
const { prepareDatabase } = await import('../../database/prepare-database.js');
|
|
73
|
+
const result = await prepareDatabase({
|
|
74
|
+
service: values.service,
|
|
75
|
+
adminUrl: env.DATABASE_ADMIN_URL,
|
|
76
|
+
ownerPassword: env.DB_OWNER_PASSWORD,
|
|
77
|
+
appPassword: env.DB_APP_PASSWORD,
|
|
78
|
+
}, logger);
|
|
79
|
+
return { database: result.database, roles: result.roles, created: result.created };
|
|
80
|
+
}
|
|
81
|
+
async function migrate(args, context, logger) {
|
|
82
|
+
const values = parseOptions(args, ['folder', 'schema']);
|
|
83
|
+
required(values, ['folder', 'schema']);
|
|
84
|
+
const { migrateEnvSchema } = await import('../../config/cli-schemas.js');
|
|
85
|
+
const env = parseEnv(migrateEnvSchema, context.env);
|
|
86
|
+
const { runMigrations } = await import('../../database/migrations.js');
|
|
87
|
+
const schema = values.schema ?? '';
|
|
88
|
+
const { applied } = await runMigrations({
|
|
89
|
+
url: env.DATABASE_MIGRATOR_URL,
|
|
90
|
+
folder: resolve(context.cwd, values.folder ?? ''),
|
|
91
|
+
schema,
|
|
92
|
+
}, logger);
|
|
93
|
+
return { schema, applied };
|
|
94
|
+
}
|
|
95
|
+
async function moveJournal(args, context, logger) {
|
|
96
|
+
const values = parseOptions(args, ['from', 'to']);
|
|
97
|
+
required(values, ['from', 'to']);
|
|
98
|
+
const { migrateEnvSchema } = await import('../../config/cli-schemas.js');
|
|
99
|
+
const env = parseEnv(migrateEnvSchema, context.env);
|
|
100
|
+
const migrations = await import('../../database/migrations.js');
|
|
101
|
+
const from = values.from ?? '';
|
|
102
|
+
const to = values.to ?? '';
|
|
103
|
+
const { moved } = await migrations.moveJournal({ url: env.DATABASE_MIGRATOR_URL, from, to }, logger);
|
|
104
|
+
return { from, to, moved };
|
|
105
|
+
}
|
|
106
|
+
const SUBCOMMANDS = {
|
|
107
|
+
prepare,
|
|
108
|
+
migrate,
|
|
109
|
+
'move-journal': moveJournal,
|
|
110
|
+
};
|
|
111
|
+
/** Перевод ошибки в JSON без адресов, паролей и текста pg; порядок проверок важен — ошибки pg тоже `Error`. */
|
|
112
|
+
async function failureOf(error) {
|
|
113
|
+
if (error instanceof CommandError) {
|
|
114
|
+
return { code: error.code, message: error.message, ...error.extra };
|
|
115
|
+
}
|
|
116
|
+
const { isConnectionError, sqlStateOf } = await import('../../internal/pg-errors.js');
|
|
117
|
+
if (isConnectionError(error)) {
|
|
118
|
+
return { code: 'DATABASE_UNAVAILABLE', message: 'БД недоступна' };
|
|
119
|
+
}
|
|
120
|
+
if (error instanceof TypeError || (error instanceof Error && error.name === 'KitUsageError')) {
|
|
121
|
+
return { code: 'USAGE', message: error.message };
|
|
122
|
+
}
|
|
123
|
+
// SQLSTATE — по цепочке cause: у DrizzleQueryError код pg во вложенной ошибке (KT-22 п. 6).
|
|
124
|
+
const code = sqlStateOf(error);
|
|
125
|
+
const sqlState = code === null ? {} : { sqlState: code };
|
|
126
|
+
return { code: 'DB_COMMAND_FAILED', message: 'команда db завершилась ошибкой', ...sqlState };
|
|
127
|
+
}
|
|
128
|
+
async function run(args, context) {
|
|
129
|
+
const sub = args[0] ?? '';
|
|
130
|
+
const handler = Object.hasOwn(SUBCOMMANDS, sub) ? SUBCOMMANDS[sub] : undefined;
|
|
131
|
+
if (handler === undefined) {
|
|
132
|
+
throw new CommandError('USAGE', SUBCOMMANDS_MESSAGE);
|
|
133
|
+
}
|
|
134
|
+
const logger = await createLogger(context.env);
|
|
135
|
+
logger.debug({ command: 'db', sub }, 'cli: команда');
|
|
136
|
+
const result = await handler(args.slice(1), context, logger);
|
|
137
|
+
context.write(`${JSON.stringify(result)}\n`);
|
|
138
|
+
}
|
|
139
|
+
/** Обработчик команды `db`: 0 — подкоманда выполнена, 1 — ошибка (JSON `{error}` в stdout). */
|
|
140
|
+
export async function dbCommand(args, context = defaultContext) {
|
|
141
|
+
try {
|
|
142
|
+
await run(args, context);
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
const failure = await failureOf(error);
|
|
147
|
+
process.stderr.write(`${JSON.stringify({ level: 'error', ...failure })}\n`);
|
|
148
|
+
context.write(`${JSON.stringify({ error: failure })}\n`);
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Окружение команды. */
|
|
2
|
+
export interface OpenApiCommandContext {
|
|
3
|
+
/** Рабочий каталог (корень проекта сервиса). */
|
|
4
|
+
readonly cwd: string;
|
|
5
|
+
/** Запись строки результата в stdout. */
|
|
6
|
+
readonly write: (line: string) => void;
|
|
7
|
+
}
|
|
8
|
+
/** Обработчик команды `openapi`: 0 — документ записан, 1 — ошибка (JSON `{error}` в stdout). */
|
|
9
|
+
export declare function openapiCommand(args: readonly string[], context?: OpenApiCommandContext): Promise<number>;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Команда `verdeect-kit openapi --out <файл> [--module <файл>#<экспорт>]` (prd.md §3.5, AC-3.7, AC-3.8). Документ —
|
|
2
|
+
// buildOpenApiDocument по метаданным модуля сервиса без создания приложения; окружение сервиса не нужно. title и version
|
|
3
|
+
// — из package.json в cwd; рядом с --out — contracts/openapi.extra.json (глубокое слияние). Подпути пакета загружаются
|
|
4
|
+
// динамическим import() (ARCHITECTURE.md «CLI»). CLI разбирает только свои ключи окружения (LOG_LEVEL, REPLICA_ID).
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
import { pathToFileURL } from 'node:url';
|
|
9
|
+
import { parseArgs } from 'node:util';
|
|
10
|
+
import { pino } from 'pino';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
/** Модуль сервиса по умолчанию (§3.5), относительно cwd. */
|
|
13
|
+
const DEFAULT_MODULE = 'dist/app.module.js#AppModule';
|
|
14
|
+
const defaultContext = {
|
|
15
|
+
cwd: process.cwd(),
|
|
16
|
+
write: (line) => void process.stdout.write(line),
|
|
17
|
+
};
|
|
18
|
+
/** Ошибка команды: JSON в stdout и код 1. */
|
|
19
|
+
class CommandError extends Error {
|
|
20
|
+
code;
|
|
21
|
+
extra;
|
|
22
|
+
constructor(code, message, extra = {}) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.extra = extra;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function createLogger() {
|
|
29
|
+
const [{ envBase }, { createLoggerOptions }] = await Promise.all([
|
|
30
|
+
import('../../config/fragments.js'),
|
|
31
|
+
import('../../observability/logger-options.js'),
|
|
32
|
+
]);
|
|
33
|
+
const parsed = z
|
|
34
|
+
.object({ LOG_LEVEL: envBase.LOG_LEVEL, REPLICA_ID: envBase.REPLICA_ID })
|
|
35
|
+
.safeParse(process.env);
|
|
36
|
+
if (!parsed.success) {
|
|
37
|
+
// Только имена ключей: значения окружения не выводятся.
|
|
38
|
+
const keys = [
|
|
39
|
+
...new Set(parsed.error.issues.map((issue) => issue.path.map(String).join('.'))),
|
|
40
|
+
];
|
|
41
|
+
throw new CommandError('INVALID_ENVIRONMENT', 'Неверное окружение', { keys });
|
|
42
|
+
}
|
|
43
|
+
return pino(...createLoggerOptions({ service: 'verdeect-kit', entry: 'cli', env: parsed.data })
|
|
44
|
+
.pinoHttp);
|
|
45
|
+
}
|
|
46
|
+
async function packageInfo(cwd) {
|
|
47
|
+
const path = join(cwd, 'package.json');
|
|
48
|
+
let manifest;
|
|
49
|
+
try {
|
|
50
|
+
manifest = JSON.parse(await readFile(path, 'utf8'));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new CommandError('INVALID_PACKAGE_JSON', `нет или не разобран ${path}`);
|
|
54
|
+
}
|
|
55
|
+
const parsed = z
|
|
56
|
+
.object({ name: z.string().min(1), version: z.string().min(1) })
|
|
57
|
+
.safeParse(manifest);
|
|
58
|
+
if (!parsed.success) {
|
|
59
|
+
throw new CommandError('INVALID_PACKAGE_JSON', `в ${path} нужны поля name и version (info.version обязателен в OpenAPI 3.1)`);
|
|
60
|
+
}
|
|
61
|
+
return { title: parsed.data.name, version: parsed.data.version };
|
|
62
|
+
}
|
|
63
|
+
async function loadModule(cwd, spec) {
|
|
64
|
+
const hash = spec.lastIndexOf('#');
|
|
65
|
+
const file = hash === -1 ? spec : spec.slice(0, hash);
|
|
66
|
+
const name = hash === -1 ? 'AppModule' : spec.slice(hash + 1);
|
|
67
|
+
const path = resolve(cwd, file);
|
|
68
|
+
if (!existsSync(path)) {
|
|
69
|
+
throw new CommandError('MODULE_NOT_FOUND', `файл модуля не найден: ${file}`);
|
|
70
|
+
}
|
|
71
|
+
const loaded = (await import(pathToFileURL(path).href));
|
|
72
|
+
const module = loaded[name];
|
|
73
|
+
if (module === undefined) {
|
|
74
|
+
throw new CommandError('MODULE_NOT_FOUND', `в ${file} нет экспорта ${name}`);
|
|
75
|
+
}
|
|
76
|
+
return module;
|
|
77
|
+
}
|
|
78
|
+
async function readExtra(path) {
|
|
79
|
+
if (!existsSync(path)) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
let value;
|
|
83
|
+
try {
|
|
84
|
+
value = JSON.parse(await readFile(path, 'utf8'));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
throw new CommandError('INVALID_EXTRA', `${path}: не разобран как JSON`);
|
|
88
|
+
}
|
|
89
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
90
|
+
throw new CommandError('INVALID_EXTRA', `${path}: ожидается объект JSON`);
|
|
91
|
+
}
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
/** Опции команды; неизвестная опция или опция без значения — `USAGE`. */
|
|
95
|
+
function parseOptions(args) {
|
|
96
|
+
try {
|
|
97
|
+
return parseArgs({
|
|
98
|
+
args: [...args],
|
|
99
|
+
options: { out: { type: 'string' }, module: { type: 'string' } },
|
|
100
|
+
strict: true,
|
|
101
|
+
}).values;
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
throw new CommandError('USAGE', error instanceof Error ? error.message : String(error));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function run(args, context) {
|
|
108
|
+
const values = parseOptions(args);
|
|
109
|
+
if (values.out === undefined) {
|
|
110
|
+
throw new CommandError('USAGE', 'нужна опция --out <файл>');
|
|
111
|
+
}
|
|
112
|
+
const logger = await createLogger();
|
|
113
|
+
logger.debug({ command: 'openapi' }, 'cli: команда');
|
|
114
|
+
const info = await packageInfo(context.cwd);
|
|
115
|
+
const module = await loadModule(context.cwd, values.module ?? DEFAULT_MODULE);
|
|
116
|
+
const { buildOpenApiDocument, deepMerge } = await import('../../http/api/openapi-document.js');
|
|
117
|
+
const out = resolve(context.cwd, values.out);
|
|
118
|
+
const built = await buildOpenApiDocument(module, info, logger);
|
|
119
|
+
const extra = await readExtra(join(dirname(out), 'openapi.extra.json'));
|
|
120
|
+
const document = extra === null ? { ...built } : deepMerge({ ...built }, extra);
|
|
121
|
+
await mkdir(dirname(out), { recursive: true });
|
|
122
|
+
await writeFile(out, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
|
|
123
|
+
const operations = Object.values(built.paths).reduce((count, item) => count + Object.keys(item).length, 0);
|
|
124
|
+
logger.info({ out: values.out, operations }, 'openapi: записан');
|
|
125
|
+
context.write(`${JSON.stringify({ written: values.out, operations })}\n`);
|
|
126
|
+
}
|
|
127
|
+
/** Обработчик команды `openapi`: 0 — документ записан, 1 — ошибка (JSON `{error}` в stdout). */
|
|
128
|
+
export async function openapiCommand(args, context = defaultContext) {
|
|
129
|
+
try {
|
|
130
|
+
await run(args, context);
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
const failure = error instanceof CommandError
|
|
135
|
+
? { code: error.code, message: error.message, ...error.extra }
|
|
136
|
+
: {
|
|
137
|
+
code: 'OPENAPI_FAILED',
|
|
138
|
+
message: error instanceof Error ? error.message : String(error),
|
|
139
|
+
};
|
|
140
|
+
process.stderr.write(`${JSON.stringify({ level: 'error', ...failure })}\n`);
|
|
141
|
+
context.write(`${JSON.stringify({ error: failure })}\n`);
|
|
142
|
+
return 1;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Окружение команды. */
|
|
2
|
+
export interface RabbitmqCommandContext {
|
|
3
|
+
/** Рабочий каталог (от него — путь `--out`). */
|
|
4
|
+
readonly cwd: string;
|
|
5
|
+
/** Запись результата в stdout. */
|
|
6
|
+
readonly write: (text: string) => void;
|
|
7
|
+
/** Переменные окружения (по умолчанию `process.env`). */
|
|
8
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
9
|
+
}
|
|
10
|
+
/** Обработчик команды `rabbitmq`: 0 — definitions собраны, 1 — ошибка (JSON `{error}` в stdout). */
|
|
11
|
+
export declare function rabbitmqCommand(args: readonly string[], context?: RabbitmqCommandContext): Promise<number>;
|